<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DynoTable</title>
    <description>The latest articles on DEV Community by DynoTable (@dynotable).</description>
    <link>https://dev.to/dynotable</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4014872%2F0b044873-dd8b-4089-b6ea-b058a67bc3dd.jpg</url>
      <title>DEV Community: DynoTable</title>
      <link>https://dev.to/dynotable</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dynotable"/>
    <language>en</language>
    <item>
      <title>DynamoDB DeleteItem in Go (AWS SDK v2)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Wed, 09 Sep 2026 13:43:48 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-deleteitem-in-go-aws-sdk-v2-454j</link>
      <guid>https://dev.to/dynotable/dynamodb-deleteitem-in-go-aws-sdk-v2-454j</guid>
      <description>&lt;p&gt;&lt;code&gt;client.DeleteItem&lt;/code&gt; takes a &lt;code&gt;dynamodb.DeleteItemInput&lt;/code&gt; carrying the &lt;a href="https://dynotable.com/learn/how-dynamodb-partition-keys-work?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;full primary key&lt;/a&gt;. With &lt;code&gt;types.ReturnValueAllOld&lt;/code&gt; the response tells you whether anything was actually there; once you add a &lt;code&gt;ConditionExpression&lt;/code&gt;, the error tells you why it stayed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"log"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/aws/aws-sdk-go-v2/aws"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/aws/aws-sdk-go-v2/config"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/aws/aws-sdk-go-v2/service/dynamodb"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TODO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadDefaultConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithRegion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"us-east-1"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"load config: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dynamodb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewFromConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeleteItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;dynamodb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeleteItemInput&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;TableName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Music"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AttributeValue&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"Artist"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AttributeValueMemberS&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Arturo Sandoval"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="s"&gt;"SongTitle"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AttributeValueMemberS&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Cubano Chant"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ReturnValues&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReturnValueAllOld&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"delete item: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No item with that key existed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Deleted:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Explanation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;types.ReturnValueAllOld&lt;/code&gt; is a typed constant&lt;/strong&gt;, not the string &lt;code&gt;"ALL_OLD"&lt;/code&gt; — the field takes a &lt;code&gt;types.ReturnValue&lt;/code&gt;, so a typo is a compile error instead of a runtime &lt;code&gt;ValidationException&lt;/code&gt;. &lt;code&gt;DeleteItem&lt;/code&gt; accepts only &lt;code&gt;NONE&lt;/code&gt; and &lt;code&gt;ALL_OLD&lt;/code&gt;; &lt;a href="https://dynotable.com/learn/dynamodb-returnvalues?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;the rest of the enum&lt;/a&gt; is shared with &lt;code&gt;UpdateItem&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;len(out.Attributes) == 0&lt;/code&gt; is the only signal you get&lt;/strong&gt; — deleting a key that was never there succeeds, and the SDK hands back a nil map rather than an error. Nothing else separates "deleted it" from "there was nothing to delete".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the guard failure with &lt;code&gt;errors.As&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;var ccfe *types.ConditionalCheckFailedException&lt;/code&gt; then &lt;code&gt;errors.As(err, &amp;amp;ccfe)&lt;/code&gt;. A direct comparison misses it, because Go v2 wraps service faults in a Smithy operation error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The exception can carry the losing item&lt;/strong&gt; — set &lt;code&gt;ReturnValuesOnConditionCheckFailure: types.ReturnValuesOnConditionCheckFailureAllOld&lt;/code&gt; and &lt;code&gt;ccfe.Item&lt;/code&gt; holds the row as DynamoDB saw it, so you can log the value that actually failed the guard instead of re-reading it. The SDK documents the price: "No read capacity units are consumed."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One item per call&lt;/strong&gt; — there is no delete-all API. &lt;a href="https://dynotable.com/learn/delete-multiple-items-dynamodb?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;Deleting many items&lt;/a&gt; means collecting the keys first and batching the writes, or dropping the table.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost note
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DeleteItem&lt;/code&gt; consumes &lt;strong&gt;1 WCU&lt;/strong&gt; per ≤1 KB of the deleted item (rounded up). Confirm item size with the &lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;item size calculator&lt;/a&gt; before you batch-delete in a loop — a 3 KB item is still 3 WCUs each. On-demand prices those units in us-east-1 the same way as provisioned metering; check the &lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt; if you are sizing a cleanup job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do it visually
&lt;/h2&gt;

&lt;p&gt;The guard is the fiddly half: a &lt;code&gt;ConditionExpression&lt;/code&gt; plus the name and value maps that go with it. The &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; assembles all three from a form.&lt;/p&gt;

&lt;p&gt;DynoTable attacks the same risk from the other end. A delete lands in a Pending changes panel first and only reaches the table when you commit it, so a wrong row is something you discard rather than something you restore. &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;Download DynoTable&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related examples
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/deleteitem-java?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;DynamoDB DeleteItem in Java&lt;/a&gt; — the same delete with AWS SDK for Java 2.x.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/putitem-go?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;DynamoDB PutItem in Go&lt;/a&gt; — the write side of the same key.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-condition-expressions?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;DynamoDB condition expressions&lt;/a&gt; — guard deletes with &lt;code&gt;attribute_exists&lt;/code&gt; and value checks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/conditionalcheckfailedexception?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=deleteitem-go" rel="noopener noreferrer"&gt;DynamoDB ConditionalCheckFailedException&lt;/a&gt; — what a failed conditional delete throws, and when it's expected.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteItem.html" rel="noopener noreferrer"&gt;DeleteItem — Amazon DynamoDB API Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/example_dynamodb_DeleteItem_section.html" rel="noopener noreferrer"&gt;Use DeleteItem with an AWS SDK or CLI — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/dynamodb" rel="noopener noreferrer"&gt;dynamodb package — AWS SDK for Go v2 (pkg.go.dev)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/dynamodb/types" rel="noopener noreferrer"&gt;dynamodb/types package — AWS SDK for Go v2 (pkg.go.dev)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html" rel="noopener noreferrer"&gt;Condition expressions — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Last verified 2026-07-28 against the official AWS documentation linked above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Conditional Write in Python (boto3)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Tue, 08 Sep 2026 13:37:40 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-conditional-write-in-python-boto3-4hak</link>
      <guid>https://dev.to/dynotable/dynamodb-conditional-write-in-python-boto3-4hak</guid>
      <description>&lt;p&gt;boto3 is the one SDK where a conditional write has a named exception class to catch, and it is also the one where the returned item hides somewhere you would not guess. The expression itself works the same everywhere; &lt;a href="https://dynotable.com/learn/dynamodb-condition-expressions?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-python" rel="noopener noreferrer"&gt;DynamoDB condition expressions&lt;/a&gt; covers the functions and the optimistic-locking pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Update the item only if nobody changed it since we read version 7.
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;TableName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Music&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Artist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Arturo Sandoval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SongTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cubano Chant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
        &lt;span class="n"&gt;UpdateExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET #upd0 = :updValue0, #version = :newVersion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attribute_exists(#cond0) AND #version = :expectedVersion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ExpressionAttributeNames&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#upd0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Genre&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#cond0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Artist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ExpressionAttributeValues&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:updValue0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Latin Jazz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:expectedVersion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;N&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:newVersion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;N&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ReturnValuesOnConditionCheckFailure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL_OLD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Updated to version 8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConditionalCheckFailedException&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# With ReturnValuesOnConditionCheckFailure="ALL_OLD", the current item
&lt;/span&gt;    &lt;span class="c1"&gt;# rides back on the exception — no extra read to see what beat you.
&lt;/span&gt;    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lost the race — item is now:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Item&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Explanation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ConditionalCheckFailedException&lt;/code&gt; is a modeled class, so &lt;code&gt;except client.exceptions.…&lt;/code&gt; works.&lt;/strong&gt; Most DynamoDB errors are not: &lt;code&gt;ValidationException&lt;/code&gt; has no class at all and has to be matched on &lt;code&gt;e.response["Error"]["Code"]&lt;/code&gt;. The modeled class still subclasses &lt;code&gt;ClientError&lt;/code&gt;, so a broad &lt;code&gt;except ClientError&lt;/code&gt; upstream will swallow it if you order your handlers carelessly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The returned item is a top-level key of &lt;code&gt;e.response&lt;/code&gt;, not of &lt;code&gt;e.response["Error"]&lt;/code&gt;.&lt;/strong&gt; That is why the fence reads &lt;code&gt;e.response.get("Item")&lt;/code&gt;. It is easy to go looking under &lt;code&gt;["Error"]&lt;/code&gt; alongside &lt;code&gt;Code&lt;/code&gt; and &lt;code&gt;Message&lt;/code&gt;, find nothing, and conclude the parameter did not work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The item comes back in DynamoDB JSON&lt;/strong&gt; even though you may be used to native values, because this is the low-level client. &lt;code&gt;boto3.dynamodb.types.TypeDeserializer&lt;/code&gt; converts it if you want plain Python.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The resource API expresses the same guard as objects,&lt;/strong&gt; &lt;code&gt;ConditionExpression=Attr("Version").eq(7) &amp;amp; Attr("Artist").exists()&lt;/code&gt;, with native values and no placeholder maps. It raises the identical exception, so the handling below is unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed check still bills a write.&lt;/strong&gt; The Developer Guide is explicit that a false condition consumes write capacity, sized on the larger of the old and new item, so an unbounded retry on a contended key costs real money while making no progress.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where boto3 puts the returned item
&lt;/h2&gt;

&lt;p&gt;Run the fence against a stored &lt;code&gt;Version&lt;/code&gt; of 9 and print the exception's response keys. DynamoDB Local 3.3.0, boto3 1.43.58:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Item&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ResponseMetadata&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Item&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Artist&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Arturo Sandoval&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                         &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Year&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;N&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1994&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                         &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Version&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;N&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                         &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SongTitle&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Cubano Chant&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                         &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AlbumTitle&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Danzon&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop &lt;code&gt;ReturnValuesOnConditionCheckFailure&lt;/code&gt; and the same failure gives &lt;code&gt;['Error', 'ResponseMetadata']&lt;/code&gt;. The &lt;code&gt;Item&lt;/code&gt; key is absent, and &lt;code&gt;e.response.get("Item")&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; rather than raising. That is the version of this bug that survives code review and starts logging &lt;code&gt;None&lt;/code&gt; in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every name in the expression is aliased
&lt;/h2&gt;

&lt;p&gt;The fence writes &lt;code&gt;#version&lt;/code&gt; and &lt;code&gt;#cond0&lt;/code&gt; instead of &lt;code&gt;Version&lt;/code&gt; and &lt;code&gt;Artist&lt;/code&gt;, which looks like overkill for two ordinary words. It is, for these two. &lt;code&gt;Version&lt;/code&gt; is not a DynamoDB reserved word, and used bare it passes name validation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Year&lt;/code&gt; is reserved, and the same table has one. Guard on it directly and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValidationException: Invalid ConditionExpression: Attribute name is a reserved keyword;
reserved keyword: Year
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;573 words are on that list, including &lt;code&gt;Name&lt;/code&gt;, &lt;code&gt;Status&lt;/code&gt;, &lt;code&gt;Size&lt;/code&gt;, &lt;code&gt;Count&lt;/code&gt;, &lt;code&gt;Data&lt;/code&gt;, &lt;code&gt;Owner&lt;/code&gt;, &lt;code&gt;Timestamp&lt;/code&gt; and &lt;code&gt;Items&lt;/code&gt;. Aliasing everything is how generated code avoids ever having to know which is which. Paste your attribute names into the &lt;a href="https://dynotable.com/tools/dynamodb-reserved-words-checker?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-python" rel="noopener noreferrer"&gt;reserved words checker&lt;/a&gt; and it returns the &lt;code&gt;ExpressionAttributeNames&lt;/code&gt; map for the ones that need it.&lt;/p&gt;

&lt;p&gt;To write these guards against your own tables with the aliasing handled for you, &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-python" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related examples
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/conditional-write-nodejs?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-python" rel="noopener noreferrer"&gt;DynamoDB conditional write in Node.js&lt;/a&gt; — the same optimistic lock with AWS SDK v3.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/conditional-write-cli?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-python" rel="noopener noreferrer"&gt;DynamoDB conditional write with the AWS CLI&lt;/a&gt; — the same optimistic lock from the shell.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/putitem-python?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-python" rel="noopener noreferrer"&gt;DynamoDB PutItem in Python&lt;/a&gt; — the create-only &lt;code&gt;attribute_not_exists&lt;/code&gt; put.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-condition-expressions?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-python" rel="noopener noreferrer"&gt;DynamoDB condition expressions&lt;/a&gt; — every function, with patterns.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-unique-multiple-attributes?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-python" rel="noopener noreferrer"&gt;Enforcing uniqueness on multiple attributes&lt;/a&gt; — conditions + transactions combined.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/conditionalcheckfailedexception?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-python" rel="noopener noreferrer"&gt;DynamoDB ConditionalCheckFailedException&lt;/a&gt; — when the failed check is expected, and how to handle it cheaply.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html" rel="noopener noreferrer"&gt;UpdateItem — Amazon DynamoDB API Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/boto3/latest/reference/services/dynamodb/client/update_item.html" rel="noopener noreferrer"&gt;DynamoDB.Client.update_item — Boto3 documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html" rel="noopener noreferrer"&gt;Condition expressions — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/read-write-operations.html" rel="noopener noreferrer"&gt;DynamoDB read and write operations (capacity unit consumption) — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html" rel="noopener noreferrer"&gt;Reserved words in DynamoDB — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Last verified 2026-07-28 against the official AWS documentation linked above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Conditional Write in Node.js (AWS SDK v3)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Mon, 07 Sep 2026 15:03:38 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-conditional-write-in-nodejs-aws-sdk-v3-3oni</link>
      <guid>https://dev.to/dynotable/dynamodb-conditional-write-in-nodejs-aws-sdk-v3-3oni</guid>
      <description>&lt;p&gt;The interesting part of a conditional write in AWS SDK v3 is not the &lt;code&gt;ConditionExpression&lt;/code&gt;, which works the same everywhere and is covered in &lt;a href="https://dynotable.com/learn/dynamodb-condition-expressions?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-nodejs" rel="noopener noreferrer"&gt;DynamoDB condition expressions&lt;/a&gt;. It is the failure path: v3 hands you the losing item on the thrown error, if you asked for it, and gives you nothing if you did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;DynamoDBClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UpdateItemCommand&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@aws-sdk/client-dynamodb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DynamoDBClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Update the item only if nobody changed it since we read version 7.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UpdateItemCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Music&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Artist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Arturo Sandoval&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;SongTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cubano Chant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;UpdateExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SET #upd0 = :updValue0, #version = :newVersion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ConditionExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attribute_exists(#cond0) AND #version = :expectedVersion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ExpressionAttributeNames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#upd0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Genre&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#cond0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Artist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;ExpressionAttributeValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:updValue0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Latin Jazz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:expectedVersion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;N&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:newVersion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;N&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;ReturnValuesOnConditionCheckFailure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ALL_OLD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Updated to version 8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ConditionalCheckFailedException&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// With ReturnValuesOnConditionCheckFailure: 'ALL_OLD', the current item&lt;/span&gt;
    &lt;span class="c1"&gt;// rides back on the exception — no extra read to see what beat you.&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lost the race — item is now:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Explanation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The failed check is a thrown error, not a status field.&lt;/strong&gt; v3 rejects the promise, so the write path and the lost-race path are different branches. &lt;code&gt;err.name === 'ConditionalCheckFailedException'&lt;/code&gt; is the discriminator; anything else has to be re-thrown, which is what the &lt;code&gt;else&lt;/code&gt; in the fence is for. Swallow the whole &lt;code&gt;catch&lt;/code&gt; and you have silently turned a throttle into a no-op.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReturnValuesOnConditionCheckFailure&lt;/code&gt; is the only way to see who beat you.&lt;/strong&gt; Without it, the error carries the message and nothing else, and you are back to a &lt;code&gt;GetItem&lt;/code&gt; you did not need. The API reference sets its valid values as &lt;code&gt;ALL_OLD | NONE&lt;/code&gt; and confirms it consumes no read capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;err.Item&lt;/code&gt; is a raw &lt;code&gt;AttributeValue&lt;/code&gt; map,&lt;/strong&gt; the same shape as the &lt;code&gt;Key&lt;/code&gt; you sent, not plain JavaScript. Run it through &lt;code&gt;unmarshall&lt;/code&gt; from &lt;code&gt;@aws-sdk/util-dynamodb&lt;/code&gt; before you compare &lt;code&gt;Version&lt;/code&gt; to a number, or you will be comparing against &lt;code&gt;{N: '9'}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failed write is still billed.&lt;/strong&gt; The Developer Guide is explicit that a condition evaluating to false still consumes write capacity, sized on the larger of the old and new item. A retry loop on a hot key is a real line on the bill, so cap the attempts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every name in the fence is aliased&lt;/strong&gt; (&lt;code&gt;#version&lt;/code&gt; → &lt;code&gt;Version&lt;/code&gt;, &lt;code&gt;#cond0&lt;/code&gt; → &lt;code&gt;Artist&lt;/code&gt;) because the &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-nodejs" rel="noopener noreferrer"&gt;Expression Builder&lt;/a&gt; that generated it aliases unconditionally. That is heavier than necessary here and never wrong, which is the trade it makes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reading the loser's copy off the exception
&lt;/h2&gt;

&lt;p&gt;Set the stored &lt;code&gt;Version&lt;/code&gt; to 9 and run the fence, which expects 7. DynamoDB Local 3.3.0 throws, and the caught error carries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;err.name     ConditionalCheckFailedException
err.message  The conditional request failed
err.$metadata.httpStatusCode  400
err.Item     {
               Artist:     { S: 'Arturo Sandoval' },
               Year:       { N: '1994' },
               Version:    { N: '9' },
               SongTitle:  { S: 'Cubano Chant' },
               AlbumTitle: { S: 'Danzon' }
             }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Version: 9&lt;/code&gt; is the whole point. The retry can go straight back through the update with &lt;code&gt;:expectedVersion&lt;/code&gt; set to 9, with no extra read and no window in which a third writer slips in between your &lt;code&gt;GetItem&lt;/code&gt; and your retry.&lt;/p&gt;

&lt;p&gt;Delete &lt;code&gt;ReturnValuesOnConditionCheckFailure&lt;/code&gt; from the same command and re-run it. Same &lt;code&gt;name&lt;/code&gt;, same &lt;code&gt;message&lt;/code&gt;, same 400, and &lt;code&gt;err.Item&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;. Nothing warns you: the parameter is optional, its absence is not an error, and the code that reads &lt;code&gt;err.Item&lt;/code&gt; just starts logging &lt;code&gt;undefined&lt;/code&gt; in production.&lt;/p&gt;

&lt;p&gt;Note also that a 400 here does not mean a malformed request. &lt;code&gt;ValidationException&lt;/code&gt; and &lt;code&gt;ConditionalCheckFailedException&lt;/code&gt; share the status code, and only one of them is a bug, which is why the branch is on &lt;code&gt;err.name&lt;/code&gt; and never on the status.&lt;/p&gt;

&lt;p&gt;To watch a condition succeed and fail against your own data, with the expression written for you rather than typed, &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-nodejs" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related examples
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/conditional-write-python?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-nodejs" rel="noopener noreferrer"&gt;DynamoDB conditional write in Python&lt;/a&gt; — the same optimistic lock with boto3.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/conditional-write-cli?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-nodejs" rel="noopener noreferrer"&gt;DynamoDB conditional write with the AWS CLI&lt;/a&gt; — the same optimistic lock from the shell.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/putitem-nodejs?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-nodejs" rel="noopener noreferrer"&gt;DynamoDB PutItem in Node.js&lt;/a&gt; — the create-only &lt;code&gt;attribute_not_exists&lt;/code&gt; put.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-condition-expressions?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-nodejs" rel="noopener noreferrer"&gt;DynamoDB condition expressions&lt;/a&gt; — every function, with patterns.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-unique-multiple-attributes?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-nodejs" rel="noopener noreferrer"&gt;Enforcing uniqueness on multiple attributes&lt;/a&gt; — conditions + transactions combined.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/conditionalcheckfailedexception?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-nodejs" rel="noopener noreferrer"&gt;DynamoDB ConditionalCheckFailedException&lt;/a&gt; — when the failed check is expected, and how to handle it cheaply.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html" rel="noopener noreferrer"&gt;UpdateItem — Amazon DynamoDB API Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html" rel="noopener noreferrer"&gt;Condition expressions — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/read-write-operations.html" rel="noopener noreferrer"&gt;DynamoDB read and write operations (capacity unit consumption) — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Last verified 2026-07-28 against the official AWS documentation linked above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Conditional Write with the AWS CLI</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:54:04 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-conditional-write-with-the-aws-cli-4im9</link>
      <guid>https://dev.to/dynotable/dynamodb-conditional-write-with-the-aws-cli-4im9</guid>
      <description>&lt;p&gt;A conditional write is straightforward to send from the shell and awkward to &lt;em&gt;read&lt;/em&gt;, because the interesting result of a failed one arrives as an error rather than as output. &lt;a href="https://dynotable.com/learn/dynamodb-condition-expressions?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-cli" rel="noopener noreferrer"&gt;DynamoDB condition expressions&lt;/a&gt; covers what the expression can say; this page is about running one from the CLI and getting the losing item back out of the failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws dynamodb update-item &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; &lt;span class="s1"&gt;'Music'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="s1"&gt;'{"Artist":{"S":"Arturo Sandoval"},"SongTitle":{"S":"Cubano Chant"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--update-expression&lt;/span&gt; &lt;span class="s1"&gt;'SET #upd0 = :updValue0, #version = :newVersion'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition-expression&lt;/span&gt; &lt;span class="s1"&gt;'attribute_exists(#cond0) AND #version = :expectedVersion'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--expression-attribute-names&lt;/span&gt; &lt;span class="s1"&gt;'{"#upd0":"Genre","#version":"Version","#cond0":"Artist"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--expression-attribute-values&lt;/span&gt; &lt;span class="s1"&gt;'{":updValue0":{"S":"Latin Jazz"},":expectedVersion":{"N":"7"},":newVersion":{"N":"8"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On success the command prints nothing and exits 0. If another writer got there first, the condition fails and the CLI reports the service message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An error occurred (ConditionalCheckFailedException) when calling the UpdateItem operation:
The conditional request failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Explanation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Success&lt;/strong&gt; — silent. No output, exit 0. There is nothing to parse and nothing to assert on, so a shell script has to treat the exit status as the result. Add &lt;code&gt;--return-values ALL_NEW&lt;/code&gt; if you want the updated item printed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure is exit status 254,&lt;/strong&gt; which is the CLI v2 code for a client-side error and is shared with a malformed request. Branch on the message before you retry, or a typo in your expression becomes an infinite backoff loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--return-values-on-condition-check-failure ALL_OLD&lt;/code&gt; does work here.&lt;/strong&gt; Valid values are &lt;code&gt;ALL_OLD&lt;/code&gt; and &lt;code&gt;NONE&lt;/code&gt;, and it consumes no read capacity. Getting the item out of the error takes one more flag, covered below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared placeholder namespace&lt;/strong&gt; — the condition and the update are separate flags, yet &lt;code&gt;--expression-attribute-names&lt;/code&gt; and &lt;code&gt;--expression-attribute-values&lt;/code&gt; are merged across &lt;code&gt;--update-expression&lt;/code&gt; and &lt;code&gt;--condition-expression&lt;/code&gt;, which is why the generated names run &lt;code&gt;#upd0&lt;/code&gt;, &lt;code&gt;#cond0&lt;/code&gt; rather than restarting per clause. Reuse a placeholder for two different meanings and the second silently wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed write is still billed.&lt;/strong&gt; The Developer Guide is explicit: a condition evaluating to false consumes write capacity anyway, sized on the larger of the old and new item. Conditions are not a cheap existence probe.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The failure output, and how to get the item out of it
&lt;/h2&gt;

&lt;p&gt;Run the fence once and it succeeds silently. Run it a second time, when &lt;code&gt;Version&lt;/code&gt; is no longer 7, and aws-cli/2.36.9 prints to stderr:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws: &lt;span class="o"&gt;[&lt;/span&gt;ERROR]: An error occurred &lt;span class="o"&gt;(&lt;/span&gt;ConditionalCheckFailedException&lt;span class="o"&gt;)&lt;/span&gt; when calling the UpdateItem operation: The conditional request failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;--return-values-on-condition-check-failure ALL_OLD&lt;/code&gt; and the default output tells you there is more, without showing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws: &lt;span class="o"&gt;[&lt;/span&gt;ERROR]: An error occurred &lt;span class="o"&gt;(&lt;/span&gt;ConditionalCheckFailedException&lt;span class="o"&gt;)&lt;/span&gt; when calling the UpdateItem operation: The conditional request failed

Additional error details:
Item: &amp;lt;complex value&amp;gt;
Use &lt;span class="s2"&gt;"--cli-error-format json"&lt;/span&gt; or another error format to see the full details.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;complex value&amp;gt;&lt;/code&gt; is the item, withheld by the default text renderer. Add &lt;code&gt;--cli-error-format json&lt;/code&gt; and the whole thing prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The conditional request failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ConditionalCheckFailedException"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Artist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Arturo Sandoval"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"N"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1994"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"N"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"SongTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cubano Chant"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"AlbumTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Danzon"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Genre"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Latin Jazz"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Attribute maps folded onto one line each; everything else is as printed.) &lt;code&gt;Version&lt;/code&gt; is 8 and &lt;code&gt;Genre&lt;/code&gt; is set because the first run succeeded. That is the optimistic-locking loop closed from a shell script: pipe stderr through &lt;code&gt;jq -r '.Item.Version.N'&lt;/code&gt;, feed it back as &lt;code&gt;:expectedVersion&lt;/code&gt;, retry. No &lt;code&gt;get-item&lt;/code&gt;, and no window between the read and the retry for a third writer to slip into.&lt;/p&gt;

&lt;p&gt;The retries are not free. Each rejected attempt consumes a write unit, so a contended key under a tight loop bills steadily while making no progress. &lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-cli" rel="noopener noreferrer"&gt;The pricing calculator&lt;/a&gt; turns a write rate into a monthly figure if you want to know what a retry storm actually costs before you cap the attempts.&lt;/p&gt;

&lt;p&gt;To run these guards against your own tables without shell-quoting the placeholder maps, &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-cli" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related examples
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/conditional-write-nodejs?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-cli" rel="noopener noreferrer"&gt;DynamoDB conditional write in Node.js&lt;/a&gt; — the same optimistic lock with AWS SDK v3.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/conditional-write-python?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-cli" rel="noopener noreferrer"&gt;DynamoDB conditional write in Python&lt;/a&gt; — the same optimistic lock with boto3.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/putitem-cli?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-cli" rel="noopener noreferrer"&gt;DynamoDB PutItem with the AWS CLI&lt;/a&gt; — the create-only &lt;code&gt;attribute_not_exists&lt;/code&gt; put.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-condition-expressions?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-cli" rel="noopener noreferrer"&gt;DynamoDB condition expressions&lt;/a&gt; — every function, with patterns.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-returnvalues?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-cli" rel="noopener noreferrer"&gt;Understanding ReturnValues&lt;/a&gt; — what each return option gives you.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/conditionalcheckfailedexception?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=conditional-write-cli" rel="noopener noreferrer"&gt;DynamoDB ConditionalCheckFailedException&lt;/a&gt; — when the failed check is expected, and how to handle it cheaply.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html" rel="noopener noreferrer"&gt;UpdateItem — Amazon DynamoDB API Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cli/latest/reference/dynamodb/update-item.html" rel="noopener noreferrer"&gt;update-item — AWS CLI Command Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html" rel="noopener noreferrer"&gt;Condition expressions — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/read-write-operations.html" rel="noopener noreferrer"&gt;DynamoDB read and write operations (capacity unit consumption) — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Last verified 2026-07-28 against the official AWS documentation linked above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Batch Write in Python (boto3 batch_writer)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sat, 05 Sep 2026 12:42:52 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-batch-write-in-python-boto3-batchwriter-4ol1</link>
      <guid>https://dev.to/dynotable/dynamodb-batch-write-in-python-boto3-batchwriter-4ol1</guid>
      <description>&lt;p&gt;&lt;code&gt;batch_writer()&lt;/code&gt; is the one DynamoDB call where Python is less work than the other SDKs. It buffers puts and deletes, cuts them into &lt;code&gt;BatchWriteItem&lt;/code&gt; requests of 25, and resends unprocessed items itself. What it does not do is protect you from the two failures that break most bulk loads, and both of them surface at the flush rather than at the line that supplied the bad item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;dynamodb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Music&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;songs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Artist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Arturo Sandoval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SongTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cubano Chant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AlbumTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Danzon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1994&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Artist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Arturo Sandoval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SongTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A Mis Abuelos&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AlbumTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Danzon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1994&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Artist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Arturo Sandoval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SongTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Groovin&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; High&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AlbumTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Swingin&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1996&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batch_writer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;song&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;songs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;song&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# batch_writer buffers deletes too — target a key you're NOT also putting
&lt;/span&gt;    &lt;span class="c1"&gt;# (two writes to the same key in one batch are rejected as a duplicate)
&lt;/span&gt;    &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Artist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ella Fitzgerald&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SongTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Misty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Buffered &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;songs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; puts + 1 delete; the batch flushes on exit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Explanation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deferred flush&lt;/strong&gt; — &lt;code&gt;batch.put_item()&lt;/code&gt; appends to a list. Nothing is validated, serialized, or sent until the buffer reaches 25 or the &lt;code&gt;with&lt;/code&gt; block exits, so the traceback for a bad item comes from the flush and not from the &lt;code&gt;put_item&lt;/code&gt; call that supplied it. If you are loading from an iterator, keep your own index of what went into the buffer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain Python values&lt;/strong&gt; — this is the resource API, so you write &lt;code&gt;1994&lt;/code&gt;, not &lt;code&gt;{"N": "1994"}&lt;/code&gt;. Decimals are required for anything fractional; a &lt;code&gt;float&lt;/code&gt; is accepted into the buffer and rejected on flush.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;batch_writer()&lt;/code&gt; is a &lt;code&gt;Table&lt;/code&gt; method.&lt;/strong&gt; The read-side counterpart is not: &lt;code&gt;batch_get_item&lt;/code&gt; lives on the &lt;code&gt;ServiceResource&lt;/code&gt;, and &lt;code&gt;table.batch_get_item&lt;/code&gt; does not exist. There is no buffering, chunking, or retry helper for batch reads at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UnprocessedItems&lt;/code&gt;, not errors&lt;/strong&gt; — that is the only retry it handles. A throttled write is resent; a &lt;code&gt;ValidationException&lt;/code&gt; propagates. Going through &lt;code&gt;client.batch_write_item&lt;/code&gt; instead hands you the whole loop, as in the &lt;a href="https://dynotable.com/dynamodb-code-examples/batchwriteitem-nodejs?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;Node.js example&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cannot lift the service limits.&lt;/strong&gt; 25 writes per request, 400 KB per item, 16 MB per request, no conditions and no updates, and every put replaces the entire stored item. Need a guard, or all-or-nothing? &lt;a href="https://dynotable.com/dynamodb-code-examples/transactwriteitems-python?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;TransactWriteItems&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What batch_writer actually does on flush
&lt;/h2&gt;

&lt;p&gt;Buffer 30 puts and watch the calls it makes. Wrapping &lt;code&gt;table.meta.client.batch_write_item&lt;/code&gt; and recording the request sizes, against DynamoDB Local 3.3.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;batch sizes sent: [25, 5]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two requests, cut at the service limit, with the remainder flushed by &lt;code&gt;__exit__&lt;/code&gt;. That flush is unconditional: raise a &lt;code&gt;RuntimeError&lt;/code&gt; inside the block and the buffered items are still written on the way out. A bulk load that dies halfway leaves a partial load behind, not a clean slate.&lt;/p&gt;

&lt;p&gt;Now the two failures. Buffer the &lt;strong&gt;same key twice&lt;/strong&gt;, which is what happens the moment your source data has a repeat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batch_writer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Artist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SongTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Artist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SongTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;botocore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;An&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="nf"&gt;occurred &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidationException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="n"&gt;calling&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt;
&lt;span class="n"&gt;BatchWriteItem&lt;/span&gt; &lt;span class="n"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Provided&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt; &lt;span class="n"&gt;duplicates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither &lt;code&gt;put_item&lt;/code&gt; complained. &lt;code&gt;batch_writer()&lt;/code&gt; does not deduplicate unless you ask it to, and asking is &lt;code&gt;table.batch_writer(overwrite_by_pkeys=["Artist", "SongTitle"])&lt;/code&gt;. Run the same two puts through that and the item stores as &lt;code&gt;Year: 2&lt;/code&gt; — the buffer keeps the last write per key, so the dedupe is silent data loss if your two rows were meant to be different items under a key you got wrong.&lt;/p&gt;

&lt;p&gt;The second one is boto3's alone and never reaches DynamoDB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Float types are not supported. Use Decimal types instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;Rating&lt;/code&gt; of &lt;code&gt;4.5&lt;/code&gt; sits in the buffer without complaint and blows up on the flush. &lt;code&gt;Decimal("4.5")&lt;/code&gt; round-trips correctly as &lt;code&gt;{"N": "4.5"}&lt;/code&gt;. Read a price or a rating out of JSON with &lt;code&gt;json.loads&lt;/code&gt; and every number is a &lt;code&gt;float&lt;/code&gt;, so this is a first-run failure for most import scripts. Passing &lt;code&gt;parse_float=Decimal&lt;/code&gt; to &lt;code&gt;json.loads&lt;/code&gt; fixes it at the source.&lt;/p&gt;

&lt;p&gt;If you are moving between native Python values and the wire format by hand, the &lt;a href="https://dynotable.com/tools/dynamodb-json-converter?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;DynamoDB JSON converter&lt;/a&gt; shows both sides of the same item so you can see what your &lt;code&gt;Decimal&lt;/code&gt; actually becomes.&lt;/p&gt;

&lt;p&gt;To bulk-load from CSV or JSON without writing the type mapping yourself, &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related examples
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/batchwriteitem-nodejs?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;DynamoDB BatchWriteItem in Node.js&lt;/a&gt; — the manual retry loop batch_writer hides.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/batchwriteitem-cli?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;DynamoDB BatchWriteItem with the AWS CLI&lt;/a&gt; — the same batch write from the shell.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-code-examples/putitem-python?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;DynamoDB PutItem in Python&lt;/a&gt; — the single-item write this batches.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-batch-operations?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;Batch operations in DynamoDB&lt;/a&gt; — limits, partial failure, and when batching pays off.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/batchwriteitem-too-many-items?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;"Too many items requested for the BatchWriteItem call"&lt;/a&gt; — more than 25 put/delete requests in one batch.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/duplicate-item-in-batchwrite?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=batchwriteitem-python" rel="noopener noreferrer"&gt;"Provided list of item keys contains duplicates"&lt;/a&gt; — two requests touching the same key in one batch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/boto3/latest/guide/dynamodb.html" rel="noopener noreferrer"&gt;Amazon DynamoDB guide (batch_writer) — Boto3 documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchWriteItem.html" rel="noopener noreferrer"&gt;BatchWriteItem — Amazon DynamoDB API Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html" rel="noopener noreferrer"&gt;Error handling with DynamoDB — Amazon DynamoDB Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Last verified 2026-07-28 against the official AWS documentation linked above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>When to Use DynamoDB (and When Not To)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Fri, 04 Sep 2026 13:32:37 +0000</pubDate>
      <link>https://dev.to/dynotable/when-to-use-dynamodb-and-when-not-to-2ffa</link>
      <guid>https://dev.to/dynotable/when-to-use-dynamodb-and-when-not-to-2ffa</guid>
      <description>&lt;p&gt;DynamoDB is a fantastic database for the workloads it's built for and a frustrating&lt;br&gt;
one for the rest. The deciding question is &lt;strong&gt;"do I know&lt;br&gt;
my access patterns up front, and are they key-based?"&lt;/strong&gt; Get that right and DynamoDB&lt;br&gt;
gives you single-digit-millisecond reads at any scale; get it wrong and you'll fight&lt;br&gt;
the lack of joins and ad-hoc queries forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I use DynamoDB?
&lt;/h2&gt;

&lt;p&gt;Use DynamoDB when your access patterns are known, key-based, and high-volume, and you&lt;br&gt;
want predictable single-digit-millisecond latency at any scale with zero servers to&lt;br&gt;
manage. Avoid it for ad-hoc queries, rich joins, or whole-dataset analytics, and when&lt;br&gt;
the data is small with query shapes that keep changing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use DynamoDB when&lt;/strong&gt; your access patterns are known, key-based, and high-volume —
and you want predictable latency at any scale with no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid it when&lt;/strong&gt; you need ad-hoc queries, rich joins, or analytics over the whole
dataset, or when the data is small and the query shapes keep changing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The core trade:&lt;/strong&gt; DynamoDB makes you design for your queries up front; in return it
never slows down as you grow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not&lt;/strong&gt; a relational database with a different syntax — modeling it like one is
the #1 source of pain.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The signals that favor DynamoDB
&lt;/h2&gt;

&lt;p&gt;DynamoDB shines when most of these hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You know your access patterns in advance.&lt;/strong&gt; You can list the exact queries the app
makes ("get a user by id", "list a user's orders newest-first") and they don't change
on a whim. DynamoDB is modeled &lt;em&gt;around&lt;/em&gt; those queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access is key-based.&lt;/strong&gt; You look items up by a known partition key, not by scanning
for arbitrary attribute combinations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale and predictable latency matter.&lt;/strong&gt; DynamoDB delivers
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html" rel="noopener noreferrer"&gt;consistent single-digit-millisecond&lt;/a&gt;
performance whether the table holds a thousand items or a billion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want zero operational overhead.&lt;/strong&gt; No instances, no failover, no vacuuming —
it's fully managed and scales to zero on-demand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write throughput is high and spiky.&lt;/strong&gt; Event logs, IoT telemetry, session/cart
state, leaderboards — append-heavy workloads with a clear key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The signals against it
&lt;/h2&gt;

&lt;p&gt;Reach for a relational database (or a search/analytics engine) instead when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your queries are ad-hoc.&lt;/strong&gt; Analysts slice the data by arbitrary columns, or
requirements change weekly. SQL's flexibility wins; DynamoDB would need a new index
per pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need real joins and aggregations across the whole dataset.&lt;/strong&gt; Reporting,
business intelligence, "sum revenue by region by month" — that's an OLAP/relational
job. (The one-off question against a live table is a different case —
&lt;a href="https://dynotable.com/learn/sql-for-dynamodb?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;DynoTable's SQL Workbench&lt;/a&gt; runs &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;,
and aggregates over DynamoDB client-side; it's the standing BI workload that
belongs elsewhere.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dataset is small and low-traffic.&lt;/strong&gt; A few thousand rows on a quiet admin app
gets no benefit from DynamoDB's scale and loses SQL's convenience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't predict access patterns yet.&lt;/strong&gt; Early-stage product still finding its
shape? A relational schema you can re-query freely is more forgiving until the
patterns settle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5t9rfnveji0fhlk5y2a6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5t9rfnveji0fhlk5y2a6.png" alt="When to Use DynamoDB (and When Not To)" width="800" height="1650"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How DynamoDB compares to other databases
&lt;/h2&gt;

&lt;p&gt;"Should I use DynamoDB or X?" is usually the same question in different clothes: &lt;strong&gt;does X&lt;br&gt;
let me postpone the access-pattern decision, and what do I pay for that?&lt;/strong&gt; DynamoDB is&lt;br&gt;
the option that refuses to let you postpone it. Every comparison below turns on that one&lt;br&gt;
trade, not on feature checklists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Relational: PostgreSQL, RDS, and Aurora
&lt;/h3&gt;

&lt;p&gt;This is the real fork, and the one most teams get wrong. A relational database lets you&lt;br&gt;
write the query &lt;em&gt;after&lt;/em&gt; you have the data. DynamoDB does not — the table is shaped by the&lt;br&gt;
queries before a single item is written.&lt;/p&gt;

&lt;p&gt;Pick relational when the query shapes are still moving, when you need joins or&lt;br&gt;
aggregates across the whole dataset, or when the data is small enough that scale is not&lt;br&gt;
the problem you have. Pick DynamoDB when the patterns are settled and key-based and you&lt;br&gt;
want them to cost the same at a billion items as at a thousand.&lt;/p&gt;

&lt;p&gt;RDS and Aurora do not change that calculus — they are managed &lt;em&gt;relational&lt;/em&gt; engines, so&lt;br&gt;
they inherit SQL's flexibility and its scaling model. What they change is the operational&lt;br&gt;
comparison: with Aurora Serverless the "no servers to manage" argument for DynamoDB gets&lt;br&gt;
much weaker, and the decision falls back cleanly onto access patterns. Aurora scales&lt;br&gt;
compute; DynamoDB removes the concept.&lt;/p&gt;

&lt;h3&gt;
  
  
  Document: MongoDB and DocumentDB
&lt;/h3&gt;

&lt;p&gt;Both store JSON-ish documents, so they look interchangeable with DynamoDB from a distance.&lt;br&gt;
They are not. MongoDB indexes any field and runs ad-hoc queries against it; DynamoDB gives&lt;br&gt;
you the partition key, the sort key, and the indexes you declared in advance.&lt;/p&gt;

&lt;p&gt;That makes MongoDB the better fit for evolving query shapes, and DynamoDB the better fit&lt;br&gt;
for known ones at high volume. DocumentDB sits on the AWS side of the same line — it&lt;br&gt;
speaks the MongoDB API, so treat it as "MongoDB's flexibility, AWS's operational model",&lt;br&gt;
and compare it to DynamoDB on exactly the flexibility-versus-predictability axis above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wide-column: Cassandra
&lt;/h3&gt;

&lt;p&gt;Cassandra is DynamoDB's closest architectural relative: partition key, clustering&lt;br&gt;
key, and the same hard truth that a bad partition key is a design bug you cannot index&lt;br&gt;
your way out of. If you are choosing between them, the deciding factors are rarely the&lt;br&gt;
data model — they are who runs it and how you pay. Cassandra you operate (or buy managed);&lt;br&gt;
DynamoDB you consume. Amazon Keyspaces is the managed-Cassandra middle ground.&lt;/p&gt;

&lt;p&gt;Because the models are so close, the modeling guidance on this site mostly transfers: the&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt; reasoning about partition keys&lt;br&gt;
and access patterns applies to Cassandra almost line for line.&lt;/p&gt;

&lt;h3&gt;
  
  
  In-memory: Redis
&lt;/h3&gt;

&lt;p&gt;Redis and DynamoDB solve different problems. Redis is memory-first and optimized for sub-millisecond access to&lt;br&gt;
data you can afford to lose or rebuild; DynamoDB is durable by default. The common&lt;br&gt;
production answer is both — DynamoDB as the system of record, Redis (or DAX, which is&lt;br&gt;
DynamoDB's own read-through cache) in front of the hot keys.&lt;/p&gt;

&lt;p&gt;Reach for Redis alone only when the data is genuinely ephemeral: rate-limit counters,&lt;br&gt;
short-lived sessions, leaderboards you can recompute.&lt;/p&gt;

&lt;h3&gt;
  
  
  Search: Elasticsearch and OpenSearch
&lt;/h3&gt;

&lt;p&gt;Search and DynamoDB solve different problems too — for a sharper reason than Redis: &lt;strong&gt;DynamoDB has no full-text&lt;br&gt;
search at all.&lt;/strong&gt; &lt;code&gt;Query&lt;/code&gt; matches on key equality and a narrow set of sort-key conditions.&lt;br&gt;
&lt;code&gt;Scan&lt;/code&gt; with a &lt;code&gt;FilterExpression&lt;/code&gt; reads every item and then discards most of them — it is a&lt;br&gt;
table walk with a filter bolted on, not a search, and you pay for the items read rather than&lt;br&gt;
the items returned. There is no relevance ranking, no analyzers, no fuzzy matching, no&lt;br&gt;
faceting.&lt;/p&gt;

&lt;p&gt;So the question is never "DynamoDB or a search engine". It is "does this workload need&lt;br&gt;
search, and if so, what feeds the index?" The standard shape is both: DynamoDB as the system&lt;br&gt;
of record, a search cluster alongside it, and DynamoDB Streams carrying every change into the&lt;br&gt;
index. That buys real search and costs you a second system to run and an index that is&lt;br&gt;
eventually consistent with the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenSearch and Elasticsearch are the same decision.&lt;/strong&gt; OpenSearch is AWS's fork of&lt;br&gt;
Elasticsearch, split at 7.10 in 2021 over Elastic's licence change, and the two have drifted&lt;br&gt;
apart since. None of that drift touches this question — for "should the search live outside&lt;br&gt;
DynamoDB", they behave identically. Choose between them on licensing, hosting and which&lt;br&gt;
managed service you want to operate, not on anything to do with DynamoDB.&lt;/p&gt;

&lt;p&gt;Reach for a search engine as the primary store only when search genuinely &lt;em&gt;is&lt;/em&gt; the product —&lt;br&gt;
log analytics, a catalogue whose main access pattern is free-text. Even then, most teams keep&lt;br&gt;
a durable store behind it, because a search index is a derived view you need to be able to&lt;br&gt;
rebuild.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cost axis, which the model comparison hides
&lt;/h3&gt;

&lt;p&gt;Every comparison above is about data models, but the surprise on the bill is usually&lt;br&gt;
structural: relational engines bill for &lt;strong&gt;capacity you provision&lt;/strong&gt;, DynamoDB bills for&lt;br&gt;
&lt;strong&gt;operations you perform&lt;/strong&gt;. That makes DynamoDB cheap for spiky and idle workloads and&lt;br&gt;
expensive for sustained scanning — the same workload can win on one engine and lose badly&lt;br&gt;
on the other with no code change between them.&lt;/p&gt;

&lt;p&gt;The multiplier people miss is indexes. On a relational engine an extra index costs storage&lt;br&gt;
and some write latency; on DynamoDB every secondary index is a &lt;strong&gt;full extra write&lt;/strong&gt; of the&lt;br&gt;
projected attributes. We worked the arithmetic out at three write volumes in&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-indexes?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;the indexes guide&lt;/a&gt; — one GSI doubles the write bill, two triple&lt;br&gt;
it. Model your real read/write mix in the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt; before you commit to either side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting the cost before you commit
&lt;/h2&gt;

&lt;p&gt;DynamoDB pricing follows reads, writes, and storage — not instance hours — so it's&lt;br&gt;
cheap for spiky and serverless workloads and can be pricey for sustained heavy scans.&lt;br&gt;
Model your real read/write mix with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;DynamoDB pricing calculator&lt;/a&gt; before committing;&lt;br&gt;
a workload that looks like a fit technically should also pencil out on cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Once you've decided it fits
&lt;/h2&gt;

&lt;p&gt;The work shifts to modeling. DynamoDB rewards designing the table &lt;em&gt;around&lt;/em&gt; your queries&lt;br&gt;
— see &lt;a href="https://dynotable.com/learn/how-to-model-data-dynamodb?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;how to model data in DynamoDB&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt; — and explicitly&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-when-not-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;when not to reach for single-table&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxfkt4dmzu2viivej1o0y.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxfkt4dmzu2viivej1o0y.webp" alt="Browsing a populated DynamoDB table in DynoTable's item grid." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls + next steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't model DynamoDB like a relational database&lt;/strong&gt; — normalized tables you join at
read time is the anti-pattern it punishes hardest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't pick it for analytics&lt;/strong&gt; — pair it with an analytics store (or export to one)
for reporting instead of scanning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsure about access patterns? Wait.&lt;/strong&gt; Adopting DynamoDB before you know your
queries is choosing the one database that demands you know them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Related:&lt;/strong&gt; &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;query vs scan&lt;/a&gt; shows what "key-based access"
actually buys you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Want to explore a DynamoDB table before betting your app on it?&lt;br&gt;
&lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;Download DynoTable&lt;/a&gt; and connect to your data directly — its&lt;br&gt;
&lt;a href="https://dynotable.com/learn/sql-for-dynamodb?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=when-to-use-dynamodb" rel="noopener noreferrer"&gt;SQL Workbench&lt;/a&gt; runs the ad-hoc &lt;code&gt;JOIN&lt;/code&gt;s and aggregates&lt;br&gt;
DynamoDB itself won't.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>How to View, Browse and Edit DynamoDB Data</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Thu, 03 Sep 2026 13:35:30 +0000</pubDate>
      <link>https://dev.to/dynotable/how-to-view-browse-and-edit-dynamodb-data-1p5</link>
      <guid>https://dev.to/dynotable/how-to-view-browse-and-edit-dynamodb-data-1p5</guid>
      <description>&lt;p&gt;Every "look at" or "change" you do to a DynamoDB table maps to one of a small set of&lt;br&gt;
API operations — &lt;code&gt;GetItem&lt;/code&gt;, &lt;code&gt;Query&lt;/code&gt;, &lt;code&gt;Scan&lt;/code&gt;, &lt;code&gt;PutItem&lt;/code&gt;, &lt;code&gt;UpdateItem&lt;/code&gt;, &lt;code&gt;DeleteItem&lt;/code&gt;.&lt;br&gt;
There's no relational table viewer underneath: "browsing a table" is literally a&lt;br&gt;
&lt;code&gt;Scan&lt;/code&gt;, and "editing a row" is an &lt;code&gt;UpdateItem&lt;/code&gt; against a &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data#primary-key" rel="noopener noreferrer"&gt;primary key&lt;/a&gt;. Knowing which&lt;br&gt;
operation each click maps to is the difference between a cheap read and a full-table&lt;br&gt;
scan you didn't mean to run.&lt;/p&gt;

&lt;p&gt;DynoTable is a GUI over exactly these operations — it shows you which one you're&lt;br&gt;
about to run, and the cost, before it hits the wire.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to browse a DynamoDB table
&lt;/h2&gt;

&lt;p&gt;Opening a table to "see what's in it" is a &lt;strong&gt;&lt;code&gt;Scan&lt;/code&gt;&lt;/strong&gt; — it reads every item in the&lt;br&gt;
table or index (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Scan.html" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;:&lt;br&gt;
"A &lt;code&gt;Scan&lt;/code&gt; operation in Amazon DynamoDB reads every item in a table or a secondary&lt;br&gt;
index."). Fine for small tables; on a large one it's the classic cost footgun&lt;br&gt;
covered in &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;query vs scan&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A single &lt;code&gt;Scan&lt;/code&gt; returns at most 1 MB of data, then hands you a &lt;code&gt;LastEvaluatedKey&lt;/code&gt; to&lt;br&gt;
fetch the next page — so "browse the whole table" is really a pagination loop&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Scan.html" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;:&lt;br&gt;
"A single &lt;code&gt;Scan&lt;/code&gt; request can retrieve a maximum of 1 MB of data" and "the&lt;br&gt;
&lt;code&gt;LastEvaluatedKey&lt;/code&gt; from a &lt;code&gt;Scan&lt;/code&gt; response should be used as the &lt;code&gt;ExclusiveStartKey&lt;/code&gt;&lt;br&gt;
for the next &lt;code&gt;Scan&lt;/code&gt; request"). See&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-pagination?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;pagination&lt;/a&gt; for how the cursor works and why offset-style page&lt;br&gt;
numbers don't exist here.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A DynamoDB GUI hides the pagination loop behind a scroll or a "next page" button,&lt;br&gt;
but it's still issuing one paged &lt;code&gt;Scan&lt;/code&gt; per page. Watch the consumed-capacity&lt;br&gt;
readout, not the row count, to know what you're spending.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  How to filter / scan DynamoDB data
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data#filter-expression" rel="noopener noreferrer"&gt;filter expression&lt;/a&gt; does not save you a scan&lt;/strong&gt;. DynamoDB applies the&lt;br&gt;
filter &lt;em&gt;after&lt;/em&gt; the read completes, so you pay for every item scanned — not just the&lt;br&gt;
rows you keep.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A filter expression is applied after a &lt;code&gt;Scan&lt;/code&gt; finishes but before the results are&lt;br&gt;
returned. Therefore, a &lt;code&gt;Scan&lt;/code&gt; consumes the same amount of read capacity,&lt;br&gt;
regardless of whether a filter expression is present.&lt;br&gt;
— &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Scan.html" rel="noopener noreferrer"&gt;AWS Scan docs&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The response makes this visible: &lt;code&gt;ScannedCount&lt;/code&gt; is "the number of items evaluated,&lt;br&gt;
before any &lt;code&gt;ScanFilter&lt;/code&gt; is applied" while &lt;code&gt;Count&lt;/code&gt; is what survived the filter&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Scan.html" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;).&lt;br&gt;
A high &lt;code&gt;ScannedCount&lt;/code&gt; with a tiny &lt;code&gt;Count&lt;/code&gt; is the signature of an inefficient scan.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Filtering a table" in any DynamoDB GUI is a filtered &lt;code&gt;Scan&lt;/code&gt; — it reads (and&lt;br&gt;
charges for) the whole table, then throws most of it away client-side. If you're&lt;br&gt;
filtering by an attribute you query often, that attribute probably wants a&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;GSI&lt;/a&gt; so you can &lt;code&gt;Query&lt;/code&gt; it instead.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  How to query a DynamoDB table
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;&lt;code&gt;Query&lt;/code&gt;&lt;/strong&gt; is the cheap, targeted read — but it requires a partition key. Per&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/QueryAndScan.html" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;:&lt;br&gt;
"You must provide the name of the partition key attribute and a single value for&lt;br&gt;
that attribute. &lt;code&gt;Query&lt;/code&gt; returns all items with that partition key value. Optionally,&lt;br&gt;
you can provide a sort key attribute and use a comparison operator to refine the&lt;br&gt;
search results."&lt;/p&gt;

&lt;p&gt;So a &lt;code&gt;Query&lt;/code&gt; reads only the items under one partition key, optionally narrowed by a&lt;br&gt;
sort-key condition — never the whole table. No partition key, no &lt;code&gt;Query&lt;/code&gt;: you're&lt;br&gt;
back to a &lt;code&gt;Scan&lt;/code&gt;. That choice is the single most important cost decision in&lt;br&gt;
DynamoDB; the full breakdown is in &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;query vs scan&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;On on-demand in &lt;code&gt;us-east-1&lt;/code&gt;, opening a table to "browse" it runs a paged &lt;code&gt;Scan&lt;/code&gt;&lt;br&gt;
that bills &lt;strong&gt;0.5 RCU per 4 KB&lt;/strong&gt; eventually-consistent per item examined — a GUI&lt;br&gt;
scroll through a &lt;strong&gt;10 GB&lt;/strong&gt; table of &lt;strong&gt;1 KB&lt;/strong&gt; rows is on the order of &lt;strong&gt;2.5&lt;br&gt;
million RCU&lt;/strong&gt; if you load everything. A targeted &lt;code&gt;Query&lt;/code&gt; on one partition key reads&lt;br&gt;
only that item collection. Estimate browse vs query in the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To assemble the &lt;code&gt;KeyConditionExpression&lt;/code&gt; / &lt;code&gt;FilterExpression&lt;/code&gt; without hand-writing&lt;br&gt;
the placeholder syntax, use the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; — it emits the&lt;br&gt;
exact names/values maps the API expects.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to edit an item in DynamoDB
&lt;/h2&gt;

&lt;p&gt;Editing one item is an &lt;strong&gt;&lt;code&gt;UpdateItem&lt;/code&gt;&lt;/strong&gt; against its full primary key. You don't&lt;br&gt;
rewrite the whole item — you supply an &lt;em&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data#update-expression" rel="noopener noreferrer"&gt;update expression&lt;/a&gt;&lt;/em&gt; naming only the&lt;br&gt;
attributes you're changing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UpdateItem
  Key:              { "PK": "USER#42", "SK": "PROFILE" }
  UpdateExpression: SET email = :e, updatedAt = :t
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two facts that trip people up, both from the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithItems.html" rel="noopener noreferrer"&gt;AWS items docs&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You must specify the entire primary key, not just part of it.&lt;/strong&gt; On a
&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data#composite-key" rel="noopener noreferrer"&gt;composite-key&lt;/a&gt; table that's partition key &lt;em&gt;and&lt;/em&gt; sort key. You can't "edit a row" by
an arbitrary attribute — that needs a scan to find the key first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UpdateItem&lt;/code&gt; is an upsert.&lt;/strong&gt; "If an item with the specified key does not exist,
&lt;code&gt;UpdateItem&lt;/code&gt; creates a new item. Otherwise, it modifies an existing item's
attributes." A typo in the key silently creates a new item instead of erroring.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inline-editing a cell in a DynamoDB GUI compiles to an &lt;code&gt;UpdateItem&lt;/code&gt; with a&lt;br&gt;
single-attribute &lt;code&gt;SET&lt;/code&gt;. The value's type tag matters — editing &lt;code&gt;"37"&lt;/code&gt; as a string&lt;br&gt;
(&lt;code&gt;S&lt;/code&gt;) vs a number (&lt;code&gt;N&lt;/code&gt;) writes a different item. See&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-data-types?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;DynamoDB data types&lt;/a&gt; for the wire format behind every cell.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How to delete an item
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;&lt;code&gt;DeleteItem&lt;/code&gt;&lt;/strong&gt;, again keyed by the full primary key:&lt;br&gt;
"&lt;code&gt;DeleteItem&lt;/code&gt; deletes the item with the specified key"&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithItems.html" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;).&lt;br&gt;
Same rule as edit — you need the whole key, so deleting "all rows where status =&lt;br&gt;
'open'" isn't one call; you scan/query to find the keys, then delete each one.&lt;br&gt;
&lt;code&gt;BatchWriteItem&lt;/code&gt; bundles up to 25 put/delete requests&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithItems.html" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;:&lt;br&gt;
"The &lt;code&gt;BatchWriteItem&lt;/code&gt; operation can contain up to 25 individual &lt;code&gt;PutItem&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;DeleteItem&lt;/code&gt; requests"), but each still targets one key — there's no &lt;code&gt;DELETE … WHERE&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to view nested / JSON data
&lt;/h2&gt;

&lt;p&gt;DynamoDB items are stored in a type-tagged wire format (DynamoDB-JSON), where every&lt;br&gt;
value carries a one- or two-letter type descriptor (&lt;code&gt;S&lt;/code&gt;, &lt;code&gt;N&lt;/code&gt;, &lt;code&gt;M&lt;/code&gt;, &lt;code&gt;L&lt;/code&gt;, &lt;code&gt;SS&lt;/code&gt;… — the&lt;br&gt;
full descriptor list is in the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html" rel="noopener noreferrer"&gt;AWS data types docs&lt;/a&gt;).&lt;br&gt;
Plain JSON has no set type, so an array round-trips as a list (&lt;code&gt;L&lt;/code&gt;), never a string&lt;br&gt;
set (&lt;code&gt;SS&lt;/code&gt;) — a real conversion limitation, not a display bug. The full type map is in&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-data-types?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;DynamoDB data types&lt;/a&gt;; to convert a DynamoDB-JSON blob to plain&lt;br&gt;
JSON and back, use the &lt;a href="https://dynotable.com/tools/dynamodb-json-converter?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;DynamoDB JSON converter&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond browse-and-edit: querying that DynamoDB can't
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Scan&lt;/code&gt;/&lt;code&gt;Query&lt;/code&gt;/&lt;code&gt;UpdateItem&lt;/code&gt; cover viewing and editing, but they can't &lt;em&gt;analyze&lt;/em&gt; —&lt;br&gt;
DynamoDB has no &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, or aggregate functions like &lt;code&gt;COUNT&lt;/code&gt;/&lt;code&gt;SUM&lt;/code&gt;, and&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-partiql-vs-sql?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;PartiQL doesn't add them either&lt;/a&gt;: its &lt;code&gt;SELECT&lt;/code&gt; grammar is&lt;br&gt;
just &lt;code&gt;SELECT … FROM table [WHERE …] [ORDER BY …]&lt;/code&gt;, with no join or grouping clause&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.select.html" rel="noopener noreferrer"&gt;AWS PartiQL SELECT reference&lt;/a&gt;),&lt;br&gt;
so each statement maps to a single Get/Query/Scan/Put/Update/Delete. DynoTable's SQL&lt;br&gt;
Workbench fills that gap by materializing your tables through DynamoDB's real query&lt;br&gt;
runtime and running SQL on top — &lt;strong&gt;SQL within DynamoDB's access-pattern rules&lt;/strong&gt; —&lt;br&gt;
but for day-to-day browse-and-edit, the operations above are the whole toolbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I view DynamoDB data without the AWS Console?&lt;/strong&gt;&lt;br&gt;
Use a desktop GUI that issues the same &lt;code&gt;Scan&lt;/code&gt;/&lt;code&gt;Query&lt;/code&gt; calls. The AWS Console&lt;br&gt;
browses tables via paged scans; a dedicated client like DynoTable does the same but&lt;br&gt;
shows the consumed capacity and the operation you're running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I edit a DynamoDB item?&lt;/strong&gt;&lt;br&gt;
Issue an &lt;code&gt;UpdateItem&lt;/code&gt; against the item's full primary key with a &lt;code&gt;SET&lt;/code&gt; update&lt;br&gt;
expression naming only the attributes you're changing. In a GUI, inline-edit the&lt;br&gt;
cell — it compiles to that &lt;code&gt;UpdateItem&lt;/code&gt; for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does filtering still cost a full scan?&lt;/strong&gt;&lt;br&gt;
Because DynamoDB applies the filter &lt;em&gt;after&lt;/em&gt; the scan reads the items. Filtered-out&lt;br&gt;
items are still read and metered. To cut cost, query by a partition key (or a&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;GSI&lt;/a&gt;) instead of scanning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I update many items at once?&lt;/strong&gt;&lt;br&gt;
There's no &lt;code&gt;UPDATE … WHERE&lt;/code&gt; — each &lt;code&gt;UpdateItem&lt;/code&gt;/&lt;code&gt;DeleteItem&lt;/code&gt; targets a single primary&lt;br&gt;
key. To change several items in one atomic request, &lt;code&gt;TransactWriteItems&lt;/code&gt; applies up to&lt;br&gt;
100 write actions (including &lt;code&gt;Update&lt;/code&gt;) that all succeed or all roll back. Otherwise you&lt;br&gt;
scan/query to collect the keys, then write each (up to 25 per &lt;code&gt;BatchWriteItem&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I browse a DynamoDB Local table the same way?&lt;/strong&gt;&lt;br&gt;
Yes — point the same GUI at the local endpoint. See&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-local?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;DynamoDB Local&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Want to browse, filter and inline-edit DynamoDB tables — and run the SQL PartiQL&lt;br&gt;
can't? &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=view-edit-dynamodb-data" rel="noopener noreferrer"&gt;Download DynoTable&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>SQL for DynamoDB and the Limits of PartiQL</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:39:41 +0000</pubDate>
      <link>https://dev.to/dynotable/sql-for-dynamodb-and-the-limits-of-partiql-4h9c</link>
      <guid>https://dev.to/dynotable/sql-for-dynamodb-and-the-limits-of-partiql-4h9c</guid>
      <description>&lt;p&gt;DynamoDB is a NoSQL key-value store, but it answers SQL-shaped questions more&lt;br&gt;
than people expect — and far less than they hope. This is the honest map: what&lt;br&gt;
SQL-on-DynamoDB you actually get out of the box, where it stops, and the few ways&lt;br&gt;
to run the &lt;code&gt;JOIN&lt;/code&gt; / &lt;code&gt;GROUP BY&lt;/code&gt; / aggregate queries the native surface can't&lt;br&gt;
express.&lt;/p&gt;
&lt;h2&gt;
  
  
  Can you query DynamoDB with SQL?
&lt;/h2&gt;

&lt;p&gt;Partly. DynamoDB ships &lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb#partiql" rel="noopener noreferrer"&gt;PartiQL&lt;/a&gt;&lt;/strong&gt;, a SQL-compatible language for&lt;br&gt;
&lt;code&gt;SELECT&lt;/code&gt;/&lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; by key, so &lt;code&gt;SELECT * FROM "Orders" WHERE&lt;br&gt;
OrderID = 100&lt;/code&gt; works. But it's a SQL-compatible surface over the DynamoDB API,&lt;br&gt;
not a SQL engine — AWS supports only a &lt;em&gt;subset&lt;/em&gt;, so &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;COUNT(*)&lt;/code&gt; are out. For those you need an engine layered on top.&lt;/p&gt;

&lt;p&gt;AWS describes PartiQL as&lt;br&gt;
"&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.html" rel="noopener noreferrer"&gt;a SQL-compatible query language, to select, insert, update, and delete data in&lt;br&gt;
Amazon DynamoDB&lt;/a&gt;",&lt;br&gt;
but is equally explicit that "Amazon DynamoDB supports a &lt;em&gt;subset&lt;/em&gt; of the PartiQL&lt;br&gt;
query language." The moment you reach for a &lt;code&gt;JOIN&lt;/code&gt;, a &lt;code&gt;GROUP BY&lt;/code&gt;, or &lt;code&gt;COUNT(*)&lt;/code&gt;,&lt;br&gt;
you're outside what PartiQL can do — see&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-partiql-vs-sql?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb" rel="noopener noreferrer"&gt;PartiQL vs SQL&lt;/a&gt; for the full feature-by-feature&lt;br&gt;
comparison.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is DynamoDB SQL or NoSQL?&lt;/strong&gt; NoSQL. DynamoDB is a managed key-value and document&lt;br&gt;
store with no relational engine, no schema, and no query planner. PartiQL gives&lt;br&gt;
it a SQL-&lt;em&gt;looking&lt;/em&gt; query language; it does not make it a relational database.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  PartiQL: a SQL-compatible surface, not a SQL engine
&lt;/h2&gt;

&lt;p&gt;PartiQL maps SQL-looking statements onto the same data-plane operations the SDK&lt;br&gt;
exposes. A &lt;code&gt;SELECT&lt;/code&gt; with a &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb#partition-key" rel="noopener noreferrer"&gt;partition-key&lt;/a&gt; equality compiles to a &lt;code&gt;Query&lt;/code&gt;; a&lt;br&gt;
&lt;code&gt;SELECT&lt;/code&gt; without one compiles to a &lt;code&gt;Scan&lt;/code&gt;. Per the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.select.html" rel="noopener noreferrer"&gt;AWS &lt;code&gt;SELECT&lt;/code&gt; reference&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Using the &lt;code&gt;SELECT&lt;/code&gt; statement can result in a full table scan if an equality or&lt;br&gt;
IN condition with a partition key is not provided in the WHERE clause.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the same access-pattern rules that govern &lt;code&gt;Query&lt;/code&gt; and &lt;code&gt;Scan&lt;/code&gt; still apply —&lt;br&gt;
PartiQL just hides them behind familiar syntax. It adds no query planner, no&lt;br&gt;
joins, and no set-based aggregation. Every statement collapses to one native&lt;br&gt;
operation:&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;SELECT&lt;/code&gt; without a partition-key equality compiles to a full-table &lt;code&gt;Scan&lt;/code&gt;. In&lt;br&gt;
&lt;code&gt;us-east-1&lt;/code&gt; on-demand that bills &lt;strong&gt;0.5 RCU per 4 KB&lt;/strong&gt; eventually-consistent for&lt;br&gt;
every item examined — a &lt;strong&gt;500 MB&lt;/strong&gt; table of &lt;strong&gt;2 KB&lt;/strong&gt; rows is roughly &lt;strong&gt;62,500&lt;br&gt;
RCU&lt;/strong&gt; before any &lt;code&gt;WHERE&lt;/code&gt; filter narrows the result set. Line-rate PartiQL-shaped&lt;br&gt;
reads in the &lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You write&lt;/th&gt;
&lt;th&gt;DynamoDB runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT … WHERE PK = …&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GetItem&lt;/code&gt; or &lt;code&gt;Query&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SELECT …&lt;/code&gt; (no PK)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Scan&lt;/code&gt; (reads the &lt;strong&gt;whole table&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INSERT INTO …&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PutItem&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UPDATE … WHERE PK=… AND SK=…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UpdateItem&lt;/code&gt; (one item)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELETE … WHERE PK=… AND SK=…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DeleteItem&lt;/code&gt; (one item)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If an operation doesn't reduce to a single Get/Query/Scan/Put/Update/Delete,&lt;br&gt;
PartiQL simply can't express it. Everything below is a consequence of that one&lt;br&gt;
fact.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Writing &lt;code&gt;SELECT * FROM "Orders" WHERE Total &amp;gt; 500&lt;/code&gt; looks like SQL, but &lt;code&gt;Total&lt;/code&gt;&lt;br&gt;
is a non-key attribute — the AWS docs list this exact &lt;code&gt;Total &amp;gt; 500&lt;/code&gt; predicate&lt;br&gt;
among the &lt;code&gt;SELECT&lt;/code&gt; statements that "will result in a full table scan"&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.select.html" rel="noopener noreferrer"&gt;reference&lt;/a&gt;).&lt;br&gt;
Same for &lt;code&gt;WHERE Total IN [500, 600]&lt;/code&gt; or &lt;code&gt;WHERE Total BETWEEN 500 AND 600&lt;/code&gt; on a&lt;br&gt;
non-key attribute. SQL syntax doesn't change DynamoDB's economics. See&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb" rel="noopener noreferrer"&gt;Query vs Scan&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  What PartiQL covers
&lt;/h2&gt;

&lt;p&gt;DynamoDB's PartiQL supports four DML/query statements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SELECT&lt;/strong&gt; — read items (compiles to &lt;code&gt;Query&lt;/code&gt; or &lt;code&gt;Scan&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INSERT&lt;/strong&gt; — add an item (&lt;code&gt;PutItem&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UPDATE&lt;/strong&gt; — modify an item (&lt;code&gt;UpdateItem&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DELETE&lt;/strong&gt; — remove an item (&lt;code&gt;DeleteItem&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also supports&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.html" rel="noopener noreferrer"&gt;transactions and batch operations&lt;/a&gt;.&lt;br&gt;
A well-formed read targets&lt;br&gt;
the partition key with an equality or &lt;code&gt;IN&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;OrderID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"Orders"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;OrderID&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderID&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ORDER BY&lt;/code&gt; is allowed, but the AWS reference restricts the ordering key to "a&lt;br&gt;
hash key or a sort key" — the partition or &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb#sort-key" rel="noopener noreferrer"&gt;sort key&lt;/a&gt;, not arbitrary columns.&lt;br&gt;
That's the ceiling of what PartiQL's &lt;code&gt;SELECT&lt;/code&gt; accepts. For copy-paste-ready&lt;br&gt;
statements, see &lt;a href="https://dynotable.com/learn/dynamodb-partiql-examples?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb" rel="noopener noreferrer"&gt;PartiQL examples&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What PartiQL can't do
&lt;/h2&gt;

&lt;p&gt;These are the things developers most often expect from "SQL," and PartiQL&lt;br&gt;
supports &lt;strong&gt;none&lt;/strong&gt; of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;JOIN&lt;/code&gt;.&lt;/strong&gt; The
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.select.html" rel="noopener noreferrer"&gt;PartiQL &lt;code&gt;SELECT&lt;/code&gt; syntax&lt;/a&gt;
is a single &lt;code&gt;FROM {{table}}[.{{index}}]&lt;/code&gt; — one table or one index, never two
tables related on a key. This is the
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb" rel="noopener noreferrer"&gt;single-table-design&lt;/a&gt; tradeoff: you model for
your access patterns up front because the query layer can't reshape data
afterward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/strong&gt; It isn't in the grammar; there's no clause to group rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No aggregate functions.&lt;/strong&gt; The
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-functions.html" rel="noopener noreferrer"&gt;PartiQL functions reference&lt;/a&gt;
lists exactly one function under "Aggregate functions": &lt;code&gt;SIZE&lt;/code&gt;, which returns
an attribute's size in bytes for a &lt;strong&gt;single&lt;/strong&gt; item. There is no &lt;code&gt;COUNT&lt;/code&gt;,
&lt;code&gt;SUM&lt;/code&gt;, &lt;code&gt;AVG&lt;/code&gt;, &lt;code&gt;MIN&lt;/code&gt;, or &lt;code&gt;MAX&lt;/code&gt; across rows. AWS states plainly: "Any SQL
functions that are not included in this list are not currently supported in
DynamoDB."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;LIKE&lt;/code&gt;, no subqueries, no &lt;code&gt;UNION&lt;/code&gt;, no window functions.&lt;/strong&gt; Pattern matching
uses &lt;code&gt;contains&lt;/code&gt; / &lt;code&gt;begins_with&lt;/code&gt;; the rest have no equivalent at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "total revenue by customer last month" — a one-line &lt;code&gt;GROUP BY&lt;/code&gt; in any&lt;br&gt;
relational database — can't be expressed in PartiQL. You'd scan the data out and&lt;br&gt;
aggregate it in application code.&lt;/p&gt;

&lt;p&gt;The only way to get real &lt;code&gt;JOIN&lt;/code&gt; / &lt;code&gt;GROUP BY&lt;/code&gt; / aggregate behavior over DynamoDB&lt;br&gt;
data is a tool that runs an &lt;strong&gt;actual SQL engine&lt;/strong&gt; on top of it. For interactive,&lt;br&gt;
ad-hoc queries there are two: Amazon Athena's federated connector, and&lt;br&gt;
DynoTable's SQL Workbench. (For scheduled analytics, DynamoDB's zero-ETL&lt;br&gt;
integration to Amazon Redshift also runs SQL joins and aggregates.)&lt;/p&gt;
&lt;h2&gt;
  
  
  How to query DynamoDB with real SQL via Amazon Athena
&lt;/h2&gt;

&lt;p&gt;AWS's own answer to "real SQL over DynamoDB" is the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/athena/latest/ug/connectors-dynamodb.html" rel="noopener noreferrer"&gt;Amazon Athena DynamoDB connector&lt;/a&gt;,&lt;br&gt;
which "enables Amazon Athena to communicate with DynamoDB so that you can query&lt;br&gt;
your tables with SQL." Because Athena is a full SQL engine, this &lt;em&gt;does&lt;/em&gt; get you&lt;br&gt;
&lt;code&gt;JOIN&lt;/code&gt; and aggregates — AWS's walkthrough is titled&lt;br&gt;
"&lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/patterns/access-query-and-join-amazon-dynamodb-tables-using-athena.html" rel="noopener noreferrer"&gt;Access, query, and join Amazon DynamoDB tables using Athena&lt;/a&gt;."&lt;/p&gt;

&lt;p&gt;The catch is setup and cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's a &lt;strong&gt;Lambda-based federated connector&lt;/strong&gt; you deploy into your account
(via the Athena console or the Serverless Application Repository), wired
through AWS Glue for schema and spilling results to an S3 bucket
(&lt;a href="https://docs.aws.amazon.com/athena/latest/ug/connectors-dynamodb.html" rel="noopener noreferrer"&gt;connector docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Under the hood it still uses DynamoDB's &lt;code&gt;Query&lt;/code&gt; and &lt;code&gt;Scan&lt;/code&gt; API operations.
AWS warns that "queries that use scans can consume a large number of read
capacity units (RCUs)," so an analytical query over a big table reads — and
meters — a lot of items
(&lt;a href="https://docs.aws.amazon.com/athena/latest/ug/connectors-dynamodb.html" rel="noopener noreferrer"&gt;connector costs&lt;/a&gt;).
Use the
&lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb" rel="noopener noreferrer"&gt;item-size calculator&lt;/a&gt; to gauge what a
scan-heavy query will cost.&lt;/li&gt;
&lt;li&gt;Write operations like &lt;code&gt;INSERT INTO&lt;/code&gt; are not supported through the connector.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Athena is the right tool for scheduled analytics and BI dashboards. It's heavy&lt;br&gt;
for the everyday "I just need to join two tables and eyeball the result" case —&lt;br&gt;
that's the gap the next section fills.&lt;/p&gt;
&lt;h2&gt;
  
  
  DynoTable SQL Workbench: SQL within DynamoDB's access-pattern rules
&lt;/h2&gt;

&lt;p&gt;DynoTable's &lt;strong&gt;SQL Workbench&lt;/strong&gt; runs real SQL — &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;COUNT&lt;/code&gt;/&lt;code&gt;SUM&lt;/code&gt;/&lt;code&gt;AVG&lt;/code&gt; — against your live DynamoDB tables from a desktop client,&lt;br&gt;
with no Lambda, Glue, or S3 to stand up. It materializes the rows through&lt;br&gt;
DynamoDB's real &lt;code&gt;Query&lt;/code&gt;/&lt;code&gt;Scan&lt;/code&gt; runtime, then runs a single &lt;code&gt;SELECT&lt;/code&gt; over them&lt;br&gt;
locally on your desktop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Runs in the DynoTable Workbench (NOT in PartiQL):&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PK&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "within DynamoDB's access-pattern rules" part matters. The Workbench doesn't&lt;br&gt;
pretend DynamoDB is Postgres — it still reads through &lt;code&gt;Query&lt;/code&gt;/&lt;code&gt;Scan&lt;/code&gt; under the&lt;br&gt;
hood, so you stay aware of what each query costs, and it enforces DynamoDB's&lt;br&gt;
access model rather than hiding it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;INNER JOIN&lt;/code&gt; and &lt;code&gt;LEFT JOIN&lt;/code&gt; only — the &lt;code&gt;ON&lt;/code&gt; target attribute must be a
partition key or GSI partition key. No &lt;code&gt;RIGHT&lt;/code&gt; / &lt;code&gt;FULL&lt;/code&gt; / &lt;code&gt;CROSS&lt;/code&gt; / comma-join.&lt;/li&gt;
&lt;li&gt;No self-joins yet, no subqueries, no derived tables, no window functions.&lt;/li&gt;
&lt;li&gt;Joins and projections operate on scalar attributes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only need to compose the conditions and key expressions for the raw API —&lt;br&gt;
not a full SQL statement — the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; generates the&lt;br&gt;
correct &lt;code&gt;FilterExpression&lt;/code&gt; / &lt;code&gt;KeyConditionExpression&lt;/code&gt; without the PartiQL surface&lt;br&gt;
at all.&lt;/p&gt;

&lt;p&gt;If your goal is a DynamoDB &lt;strong&gt;SQL client&lt;/strong&gt; for exploring, debugging, and analyzing&lt;br&gt;
tables, the Workbench fills that gap — and the rest of DynoTable is a full&lt;br&gt;
&lt;a href="https://dynotable.com/compare/best-dynamodb-gui-clients?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb" rel="noopener noreferrer"&gt;DynamoDB GUI&lt;/a&gt; around it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=sql-for-dynamodb" rel="noopener noreferrer"&gt;Try DynoTable&lt;/a&gt; to run real SQL against your own tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can you run SQL on DynamoDB?&lt;/strong&gt;&lt;br&gt;
You can run PartiQL, a SQL-compatible subset (SELECT/INSERT/UPDATE/DELETE by&lt;br&gt;
key). For JOIN, GROUP BY and aggregates you need a SQL engine on top: the Amazon&lt;br&gt;
Athena DynamoDB connector, or DynoTable's SQL Workbench — a single SELECT dialect&lt;br&gt;
with INNER/LEFT JOIN, no CTEs, unions or subqueries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does DynamoDB PartiQL support JOIN?&lt;/strong&gt;&lt;br&gt;
No. The PartiQL &lt;code&gt;SELECT&lt;/code&gt; syntax has a single &lt;code&gt;FROM&lt;/code&gt; table or index and no join&lt;br&gt;
grammar. Joins require an engine layered over DynamoDB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does PartiQL support GROUP BY or aggregates like COUNT and SUM?&lt;/strong&gt;&lt;br&gt;
No. There's no &lt;code&gt;GROUP BY&lt;/code&gt; clause, and the only "aggregate" function is &lt;code&gt;SIZE&lt;/code&gt;&lt;br&gt;
(an attribute's byte size for one item). &lt;code&gt;COUNT&lt;/code&gt;, &lt;code&gt;SUM&lt;/code&gt;, &lt;code&gt;AVG&lt;/code&gt;, &lt;code&gt;MIN&lt;/code&gt;, and &lt;code&gt;MAX&lt;/code&gt;&lt;br&gt;
across rows aren't supported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is DynamoDB SQL or NoSQL?&lt;/strong&gt;&lt;br&gt;
NoSQL — a key-value and document store. PartiQL adds a SQL-compatible query&lt;br&gt;
language on top, but DynamoDB has no relational engine, joins, or aggregates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is PartiQL good for ad-hoc queries?&lt;/strong&gt;&lt;br&gt;
For key-based lookups, yes. For analytical ad-hoc queries (counts, rollups,&lt;br&gt;
joins), no — PartiQL can't express them, and unconstrained &lt;code&gt;SELECT&lt;/code&gt;s silently&lt;br&gt;
become full table scans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a DynamoDB SQL client that handles JOIN and GROUP BY?&lt;/strong&gt;&lt;br&gt;
Yes — DynoTable's SQL Workbench runs &lt;code&gt;JOIN&lt;/code&gt;/&lt;code&gt;GROUP BY&lt;/code&gt;/aggregates against live&lt;br&gt;
tables from the desktop, and Amazon Athena does it via a federated connector you&lt;br&gt;
deploy in your AWS account.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>How to Query DynamoDB with AI (Natural Language)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:10:04 +0000</pubDate>
      <link>https://dev.to/dynotable/how-to-query-dynamodb-with-ai-natural-language-53nf</link>
      <guid>https://dev.to/dynotable/how-to-query-dynamodb-with-ai-natural-language-53nf</guid>
      <description>&lt;p&gt;"Show me last week's failed orders" is a one-liner in your head and a&lt;br&gt;
&lt;code&gt;KeyConditionExpression&lt;/code&gt; with placeholder maps on the wire. Closing that gap&lt;br&gt;
is what "query DynamoDB with AI" actually means — because the DynamoDB API&lt;br&gt;
itself has &lt;strong&gt;no natural-language endpoint&lt;/strong&gt;. Every request is still&lt;br&gt;
expressions or &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai#partiql" rel="noopener noreferrer"&gt;PartiQL&lt;/a&gt;; AI sits in the tooling layer, translating&lt;br&gt;
your intent into them.&lt;/p&gt;

&lt;p&gt;That translation can be genuinely great or quietly dangerous, depending on&lt;br&gt;
one thing: &lt;strong&gt;whether the model can see your real schema&lt;/strong&gt;. This guide covers&lt;br&gt;
the three working setups and where each one breaks.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do I query DynamoDB using natural language?
&lt;/h2&gt;

&lt;p&gt;Three real options: ask a general LLM to draft PartiQL and run it yourself&lt;br&gt;
(fast, but the model is guessing your attribute names), connect an AI agent&lt;br&gt;
to DynamoDB through an &lt;a href="https://dynotable.com/learn/dynamodb-mcp-server?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; so it can&lt;br&gt;
inspect and query for real, or use a DynamoDB client with a built-in&lt;br&gt;
schema-aware agent — &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;DynoTable&lt;/a&gt;'s AI chat turns plain&lt;br&gt;
language into PartiQL or SQL against your actual indexed schema, computes&lt;br&gt;
exact whole-table aggregates, and stages any write for your review.&lt;/p&gt;
&lt;h2&gt;
  
  
  Option 1: LLM-drafted PartiQL, run by you
&lt;/h2&gt;

&lt;p&gt;Describe the query to any capable model, get PartiQL&lt;br&gt;
back, run it in the console's PartiQL editor or via the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws dynamodb execute-statement &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--statement&lt;/span&gt; &lt;span class="s2"&gt;"SELECT * FROM &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Orders&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; WHERE PK = 'ORDER#1001'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works — and it fails in three predictable ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model can't see your table.&lt;/strong&gt; It will confidently invent attribute
names and key shapes (&lt;code&gt;orderId&lt;/code&gt; when yours is &lt;code&gt;PK = ORDER#&amp;lt;id&amp;gt;&lt;/code&gt;). You end
up debugging hallucinated schema instead of writing the query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PartiQL's limits still apply.&lt;/strong&gt; A &lt;code&gt;SELECT&lt;/code&gt; reads exactly one table, and a
&lt;code&gt;WHERE&lt;/code&gt; that doesn't pin a key becomes a &lt;strong&gt;full table scan&lt;/strong&gt; — silently
expensive, exactly as if you'd written it yourself. The model rarely warns
you; &lt;a href="https://dynotable.com/learn/dynamodb-partiql-vs-sql?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;PartiQL vs SQL&lt;/a&gt; explains what the SQL surface
does and doesn't buy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pasting schema or data into a chatbot is a data-governance decision.&lt;/strong&gt;
Sample items in a prompt are production data leaving your boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Good for one-off queries on a schema you can paste from memory; shaky as a&lt;br&gt;
workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: an AI agent connected via MCP
&lt;/h2&gt;

&lt;p&gt;The structural fix for "the model can't see your table" is to give the agent&lt;br&gt;
&lt;strong&gt;tools&lt;/strong&gt; instead of a prompt-pasted schema. The Model Context Protocol (MCP)&lt;br&gt;
does exactly that: an MCP server exposes DynamoDB operations as typed tools,&lt;br&gt;
and any MCP-capable agent (Claude, IDE assistants, custom agents) can list&lt;br&gt;
tables, inspect keys, and run queries with real results feeding back into the&lt;br&gt;
conversation.&lt;/p&gt;

&lt;p&gt;We cover the full setup — and the consent, scoping, and write-safety&lt;br&gt;
questions that come with letting an agent touch a database — in&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-mcp-server?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;Using DynamoDB with an MCP server&lt;/a&gt;. DynoTable&lt;br&gt;
itself ships one: it can expose gated, loopback-only tools to external&lt;br&gt;
agents, with per-connection consent and scopes.&lt;/p&gt;

&lt;p&gt;This is the right architecture when the &lt;em&gt;agent&lt;/em&gt; is the product — a support&lt;br&gt;
bot, an internal Slack assistant. For interactive day-to-day work, it still&lt;br&gt;
leaves you assembling agent, server, and credentials yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: a schema-aware agent in your DynamoDB client
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;DynoTable&lt;/a&gt;'s built-in agent is the integrated version: it&lt;br&gt;
lives next to your tables (⌘;), and it reads your &lt;strong&gt;indexed&lt;br&gt;
schema&lt;/strong&gt; — the tables under the active profile, their keys and indexes, the&lt;br&gt;
attribute paths and types discovered by&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-table-size-item-count?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;table indexing&lt;/a&gt;, even sample values —&lt;br&gt;
so "filter this to last week" resolves against your real attribute names, not&lt;br&gt;
a guess. Type &lt;code&gt;@&lt;/code&gt; to reference a &lt;code&gt;@table&lt;/code&gt;, &lt;code&gt;@column&lt;/code&gt;, or &lt;code&gt;@gsi&lt;/code&gt; explicitly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7a5aetqbm9fepsak7nss.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7a5aetqbm9fepsak7nss.webp" alt="The DynoTable AI chat docked beside a table tab: a plain-language question, the generated query, and the result opening as a view." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What it does with a question, per the capability catalog:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Writes the query for you&lt;/strong&gt; — read-only PartiQL, or Workbench SQL when the
question needs &lt;code&gt;JOIN&lt;/code&gt; / &lt;code&gt;GROUP BY&lt;/code&gt; / aggregates (the analytics DynamoDB's
API &lt;a href="https://dynotable.com/learn/dynamodb-join?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;doesn't have&lt;/a&gt;) — and proposes the result as a
&lt;strong&gt;chip&lt;/strong&gt; you click to open as a real tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Computes exact whole-table answers.&lt;/strong&gt; Ask for a count, sum, average, or
per-group breakdown and it reads &lt;strong&gt;every matching item&lt;/strong&gt;, not a sampled
page — "how many orders last month?" reflects the actual table. It can
reshape the same pass into a transformed export file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stages every write.&lt;/strong&gt; Ask it to fix a row and the change lands in the
&lt;a href="https://dynotable.com/learn/view-edit-dynamodb-data?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;staging area&lt;/a&gt; as a reviewable diff — the
agent &lt;strong&gt;cannot&lt;/strong&gt; write to DynamoDB directly, batch-delete a table, or
alter table structure, in any permission mode. You review, you commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asks before spending your money.&lt;/strong&gt; Reads that hit AWS and cost capacity
are permission-gated (Manual / Auto / Full Auto, per profile), and every
gated decision lands in a local, always-on audit log.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trust model matters as much as the features: the agent runs on &lt;strong&gt;your&lt;br&gt;
own AWS Bedrock credentials&lt;/strong&gt;, talking directly to Bedrock in your account —&lt;br&gt;
prompts, schema, and table data never route through DynoTable's servers, and&lt;br&gt;
inference is billed to you at Bedrock's own rates with no markup. Tool&lt;br&gt;
results are treated as untrusted data, so a row containing "ignore previous&lt;br&gt;
instructions" can't hijack the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI does NOT change about DynamoDB
&lt;/h2&gt;

&lt;p&gt;Any honest AI layer inherits the database's physics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access patterns still rule.&lt;/strong&gt; "Where status = X" over a non-key attribute
is a filtered scan no matter who writes it — the model just types the
expensive query faster. If a question keeps forcing scans, the fix is
modeling (a &lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;GSI&lt;/a&gt;, a better
&lt;a href="https://dynotable.com/learn/dynamodb-sort-key-strategies?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;sort key&lt;/a&gt;), not a better prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads cost real capacity.&lt;/strong&gt; An exact whole-table aggregate is a
whole-table read. Good tools gate it and say so; the
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt; tells you what a
full pass costs before you approve it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Determinism has its place.&lt;/strong&gt; For a query you'll run forever in
production, hand-build the expression once in the
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;Expression Builder&lt;/a&gt; and ship the exact
names/values maps — AI is for exploration, the builder is for the code you
commit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I query DynamoDB in plain English?&lt;/strong&gt;&lt;br&gt;
Not against the API itself — DynamoDB only speaks expressions and PartiQL.&lt;br&gt;
But an AI layer can translate: an LLM drafting PartiQL, an MCP-connected&lt;br&gt;
agent, or a schema-aware agent like DynoTable's that generates and runs&lt;br&gt;
the query against your real schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does DynamoDB have a built-in AI query feature?&lt;/strong&gt;&lt;br&gt;
The DynamoDB API has no natural-language endpoint. Whatever AI querying you&lt;br&gt;
get comes from the tooling layer on top — which is why the safety model of&lt;br&gt;
that tooling (read gates, staged writes, your own credentials) is the thing&lt;br&gt;
to evaluate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to let AI near production data?&lt;/strong&gt;&lt;br&gt;
It's a permissions question. Look for: reads gated behind explicit approval,&lt;br&gt;
writes that land in a reviewable staging area instead of executing directly,&lt;br&gt;
an audit log, and inference on credentials you control. DynoTable's&lt;br&gt;
agent meets all four; a chatbot with your pasted data meets none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the AI join tables or do GROUP BY?&lt;/strong&gt;&lt;br&gt;
Not via the DynamoDB API — &lt;a href="https://dynotable.com/learn/dynamodb-join?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;no engine exists for it&lt;/a&gt;.&lt;br&gt;
DynoTable's agent answers those questions through its Workbench SQL (real &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, and aggregates within DynamoDB's access-pattern&lt;br&gt;
rules), which is also where &lt;a href="https://dynotable.com/learn/dynamodb-count-sum-aggregate?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;count/sum/average&lt;/a&gt;&lt;br&gt;
questions land.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does it cost?&lt;/strong&gt;&lt;br&gt;
Two meters: DynamoDB read capacity for whatever the queries touch (the&lt;br&gt;
agent asks before gated reads), and Bedrock inference billed to your own&lt;br&gt;
AWS account — DynoTable adds no markup and proxies nothing.&lt;/p&gt;

&lt;p&gt;Ask your next question in plain language — &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=query-dynamodb-with-ai" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt;,&lt;br&gt;
point AI at your own Bedrock, and keep every write behind a review.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>One-to-Many Relationships in DynamoDB</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Mon, 31 Aug 2026 17:04:27 +0000</pubDate>
      <link>https://dev.to/dynotable/one-to-many-relationships-in-dynamodb-36if</link>
      <guid>https://dev.to/dynotable/one-to-many-relationships-in-dynamodb-36if</guid>
      <description>&lt;p&gt;A SaaS control plane almost always has a containment hierarchy: one &lt;strong&gt;workspace&lt;/strong&gt;&lt;br&gt;
owns many &lt;strong&gt;projects&lt;/strong&gt;. In SQL you'd put a &lt;code&gt;workspace_id&lt;/code&gt; foreign key on the&lt;br&gt;
projects table and &lt;code&gt;JOIN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;DynamoDB has no joins and no foreign keys, so the relationship has to live in the&lt;br&gt;
&lt;strong&gt;key schema&lt;/strong&gt; itself. Done right, "load a workspace and every project inside it"&lt;br&gt;
becomes a single &lt;code&gt;Query&lt;/code&gt; instead of one read plus a follow-up scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you model a one-to-many relationship in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;Give the parent and all its children the same &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt; so they share one &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb#item-collection" rel="noopener noreferrer"&gt;item collection&lt;/a&gt;, then differentiate them with the sort key. DynamoDB has no joins or foreign keys, so the relationship lives in the key schema itself. Loading a parent plus every child then becomes a single &lt;code&gt;Query&lt;/code&gt; instead of a join.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model the reads, not the entities.&lt;/strong&gt; The one-to-many relationship only exists
to serve "list a workspace's projects" — shape the keys around that query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode the parent into the child's &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt;.&lt;/strong&gt; Give the workspace and all
its projects the same partition-key value so they land in one &lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb#item-collection" rel="noopener noreferrer"&gt;item collection&lt;/a&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then the list read is one &lt;code&gt;Query&lt;/code&gt;.&lt;/strong&gt; Parent plus its children come back
together — no join, no second round trip (a &lt;code&gt;Query&lt;/code&gt; returns up to 1 MB per
page, paginating via &lt;code&gt;LastEvaluatedKey&lt;/code&gt; beyond that).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb#hot-partition" rel="noopener noreferrer"&gt;hot partition&lt;/a&gt;.&lt;/strong&gt; One huge tenant concentrates all its traffic on one
partition; a giant workspace may need a sharded key and a fan-out read.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The access pattern, first
&lt;/h2&gt;

&lt;p&gt;DynamoDB modeling is access-pattern-first, not entity-first — the same discipline&lt;br&gt;
behind &lt;a href="https://dynotable.com/learn/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;. Before choosing any&lt;br&gt;
key, write down the reads the app actually issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Get one workspace's settings.&lt;/li&gt;
&lt;li&gt;List every project in a workspace, newest-first.&lt;/li&gt;
&lt;li&gt;Get one specific project by id.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "one workspace, many projects" relationship only matters because of read #2.&lt;br&gt;
If you never needed to list a workspace's projects together, you wouldn't model&lt;br&gt;
the relationship at all — you'd store projects independently.&lt;/p&gt;

&lt;p&gt;So the question is never "how do I represent one-to-many?" in the abstract. It's&lt;br&gt;
"which queries must this relationship serve?" Answer that, then shape the keys&lt;br&gt;
around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a foreign key won't help here
&lt;/h2&gt;

&lt;p&gt;In DynamoDB every &lt;code&gt;GetItem&lt;/code&gt; and &lt;code&gt;Query&lt;/code&gt; targets a &lt;strong&gt;partition key&lt;/strong&gt;, and the&lt;br&gt;
service hashes that key to locate the partition holding the item.&lt;/p&gt;

&lt;p&gt;AWS says so directly in the &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html" rel="noopener noreferrer"&gt;Core Components&lt;/a&gt;&lt;br&gt;
docs: the partition-key value is the input to an internal hash function that&lt;br&gt;
decides where data lives.&lt;/p&gt;

&lt;p&gt;That hash-based placement is the inheritance from the original 2007 &lt;em&gt;Dynamo:&lt;br&gt;
Amazon's Highly Available Key-value Store&lt;/em&gt; paper, where consistent hashing&lt;br&gt;
distributes keys across nodes.&lt;/p&gt;

&lt;p&gt;A bare &lt;code&gt;workspace_id&lt;/code&gt; &lt;em&gt;attribute&lt;/em&gt; on a project item is invisible to that&lt;br&gt;
machinery — DynamoDB can't "follow" it.&lt;/p&gt;

&lt;p&gt;To fetch related items in one request, the parent's identity must be encoded into&lt;br&gt;
the project's &lt;strong&gt;partition key&lt;/strong&gt;, so all of a workspace's items hash to the same&lt;br&gt;
partition and one &lt;code&gt;Query&lt;/code&gt; can sweep them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked example: workspaces and projects
&lt;/h2&gt;

&lt;p&gt;Use a generic, overloaded key schema. Call the partition key &lt;code&gt;EntityRef&lt;/code&gt; and the&lt;br&gt;
sort key &lt;code&gt;Detail&lt;/code&gt;. The workspace identity goes into &lt;code&gt;EntityRef&lt;/code&gt; for &lt;strong&gt;both&lt;/strong&gt; the&lt;br&gt;
workspace item and every project under it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;EntityRef&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;th&gt;attributes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WS#acme&lt;/td&gt;
&lt;td&gt;META&lt;/td&gt;
&lt;td&gt;displayName, region, seatLimit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WS#acme&lt;/td&gt;
&lt;td&gt;PROJ#2026-0007&lt;/td&gt;
&lt;td&gt;title, status, createdBy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WS#acme&lt;/td&gt;
&lt;td&gt;PROJ#2026-0042&lt;/td&gt;
&lt;td&gt;title, status, createdBy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WS#acme&lt;/td&gt;
&lt;td&gt;PROJ#2026-0118&lt;/td&gt;
&lt;td&gt;title, status, createdBy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WS#globex&lt;/td&gt;
&lt;td&gt;META&lt;/td&gt;
&lt;td&gt;displayName, region, seatLimit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WS#globex&lt;/td&gt;
&lt;td&gt;PROJ#2026-0009&lt;/td&gt;
&lt;td&gt;title, status, createdBy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The workspace and all its projects share &lt;code&gt;EntityRef = "WS#acme"&lt;/code&gt;, so they form a&lt;br&gt;
single &lt;strong&gt;item collection&lt;/strong&gt; living together on one partition.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Detail&lt;/code&gt; sort key separates them: &lt;code&gt;META&lt;/code&gt; is the workspace record, and each&lt;br&gt;
project carries a &lt;code&gt;PROJ#&lt;/code&gt; prefix with a zero-padded, time-ordered id so projects&lt;br&gt;
sort naturally.&lt;/p&gt;

&lt;p&gt;Visually, the parent and its children stack inside one partition, ordered by the&lt;br&gt;
sort key:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr9lrvspwbwlww6aop78p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr9lrvspwbwlww6aop78p.png" alt="One-to-Many Relationships in DynamoDB" width="800" height="1510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;Query&lt;/code&gt; on &lt;code&gt;EntityRef = "WS#acme"&lt;/code&gt; sweeps the whole stack — parent plus every&lt;br&gt;
child — in a single read.&lt;/p&gt;

&lt;p&gt;Now the three access patterns each collapse to one call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Workspace settings&lt;/strong&gt; — &lt;code&gt;GetItem(EntityRef="WS#acme", Detail="META")&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List projects newest-first&lt;/strong&gt; — &lt;code&gt;Query(EntityRef="WS#acme")&lt;/code&gt; with
&lt;code&gt;Detail begins_with "PROJ#"&lt;/code&gt;, run in descending order
(&lt;code&gt;ScanIndexForward = false&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One project&lt;/strong&gt; — &lt;code&gt;GetItem(EntityRef="WS#acme", Detail="PROJ#2026-0042")&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one is the point. The parent and its children come back from&lt;br&gt;
&lt;strong&gt;one&lt;/strong&gt; &lt;code&gt;Query&lt;/code&gt;, no join and no second round trip — DynamoDB returns up to 1 MB&lt;br&gt;
per page and hands you a &lt;code&gt;LastEvaluatedKey&lt;/code&gt; to fetch the rest. That's the move&lt;br&gt;
you can't make with a foreign-key attribute and a &lt;code&gt;Scan&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Interactive access pattern explorer: &lt;a href="https://dynotable.com/learn/one-to-many-relationships-dynamodb?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;try it on dynotable.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Writing that &lt;code&gt;begins_with&lt;/code&gt; condition by hand is fiddly — the key-condition and&lt;br&gt;
projection-expression syntax bites.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; generates&lt;br&gt;
the &lt;code&gt;KeyConditionExpression&lt;/code&gt;, the &lt;code&gt;#name&lt;/code&gt;/&lt;code&gt;:value&lt;/code&gt; placeholder maps, and a&lt;br&gt;
ready-to-run SDK snippet so you don't fight the grammar:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KeyConditionExpression     "#er = :er AND begins_with(#d, :p)"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ExpressionAttributeNames   { "#er": "EntityRef", "#d": "Detail" }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ExpressionAttributeValues  { ":er": "WS#acme", ":p": "PROJ#" }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Inspect the item collection in DynoTable
&lt;/h2&gt;

&lt;p&gt;Every row sharing an &lt;code&gt;EntityRef&lt;/code&gt; is the&lt;br&gt;
workspace plus its children, sitting next to each other.&lt;/p&gt;

&lt;p&gt;DynoTable groups them so you see the one-to-many relationship as one contiguous&lt;br&gt;
block instead of guessing at it across separate tables.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyu8g089lzc3czqvhrvz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyu8g089lzc3czqvhrvz.webp" alt="The workspace META item and its PROJ# children grouped as one item collection in DynoTable's table view." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls and the alternative shape
&lt;/h2&gt;

&lt;p&gt;A few things to watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hot partitions.&lt;/strong&gt; Every item for one workspace lives on one partition, so a
single very large or very busy tenant concentrates traffic. The
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-design.html" rel="noopener noreferrer"&gt;adaptive capacity&lt;/a&gt;
behavior AWS describes absorbs moderate skew, but a workspace with millions of
projects may need a sharded key (e.g. &lt;code&gt;WS#acme#01 … #10&lt;/code&gt;) and a fan-out read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Item-collection size.&lt;/strong&gt; With a local secondary index, a single partition's
item collection is capped at 10 GB; without an LSI there's no such limit. If
you're weighing index types here, see
&lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;GSI vs LSI&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for &lt;code&gt;Query&lt;/code&gt;, never &lt;code&gt;Scan&lt;/code&gt;.&lt;/strong&gt; The whole design exists so you can &lt;code&gt;Query&lt;/code&gt;
one partition. Falling back to a filtered &lt;code&gt;Scan&lt;/code&gt; to "find a workspace's
projects" throws the model away and reads the entire table — the trap covered
in &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;Query vs Scan&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you genuinely need to list projects &lt;strong&gt;across&lt;/strong&gt; workspaces (say, all&lt;br&gt;
&lt;code&gt;status = ACTIVE&lt;/code&gt; projects globally), the base table can't answer that — its&lt;br&gt;
partition key is workspace-scoped.&lt;/p&gt;

&lt;p&gt;That's a job for a secondary index that re-partitions projects on a different&lt;br&gt;
attribute, not for reshaping this relationship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;Model the access patterns, encode the parent into the child's partition key, and&lt;br&gt;
the one-to-many read is a single &lt;code&gt;Query&lt;/code&gt;. Build and validate the key condition&lt;br&gt;
with the &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; — and&lt;br&gt;
if you'd rather start from the access patterns themselves, the free&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;Single-Table Design tool&lt;/a&gt; drafts the&lt;br&gt;
PK/SK/GSI plan with example items.&lt;/p&gt;

&lt;p&gt;Then &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt; to load this schema, browse the&lt;br&gt;
workspace→projects item collection live, and confirm each query does exactly one&lt;br&gt;
read. If you'd rather see workspaces and projects as a joined, relational view,&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-join?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=one-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;DynoTable's SQL Workbench&lt;/a&gt; runs that &lt;code&gt;JOIN&lt;/code&gt; too.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>Many-to-Many Relationships in DynamoDB</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sun, 30 Aug 2026 14:25:29 +0000</pubDate>
      <link>https://dev.to/dynotable/many-to-many-relationships-in-dynamodb-3mo</link>
      <guid>https://dev.to/dynotable/many-to-many-relationships-in-dynamodb-3mo</guid>
      <description>&lt;p&gt;A student enrolls in many courses; a course holds many students. In SQL you reach&lt;br&gt;
for a join table and a three-table &lt;code&gt;JOIN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;DynamoDB has no joins, so the relationship has to live in the &lt;strong&gt;keys&lt;/strong&gt; — and the&lt;br&gt;
trick is to store each enrollment edge in a shape that both sides can &lt;code&gt;Query&lt;/code&gt;&lt;br&gt;
directly.&lt;/p&gt;

&lt;p&gt;This guide walks the students ↔ courses problem end to end: the access patterns, the&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb#adjacency-list" rel="noopener noreferrer"&gt;adjacency-list&lt;/a&gt; pattern that solves them, an original key schema you can copy, and how&lt;br&gt;
to read both directions back without ever scanning the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you model a many-to-many relationship in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;DynamoDB has no joins, so you model a many-to-many relationship with the &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb#adjacency-list" rel="noopener noreferrer"&gt;adjacency-list&lt;/a&gt; pattern: store each link as its own edge item keyed by one side, then add an inverted GSI that swaps the keys. A single edge, written once, then answers queries from both directions cheaply.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store each enrollment as its own edge item&lt;/strong&gt;, not a list attribute on either side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key the edge by the student&lt;/strong&gt; (&lt;code&gt;PK = STU#…&lt;/code&gt;, &lt;code&gt;SK = ENROLL#CRS#…&lt;/code&gt;) so one &lt;code&gt;Query&lt;/code&gt;
returns a student's whole course list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an inverted &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb#gsi" rel="noopener noreferrer"&gt;GSI&lt;/a&gt;&lt;/strong&gt; that swaps the roles (&lt;code&gt;GSI1PK = CRS#…&lt;/code&gt;) so the same edge
also answers "who's in this course?".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One edge, written once, reads cheaply both ways&lt;/strong&gt; — that's the entire game.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frame the access patterns first
&lt;/h2&gt;

&lt;p&gt;DynamoDB modeling is access-pattern-first: you decide the reads before you pick a&lt;br&gt;
single attribute name. A many-to-many relationship almost always has &lt;strong&gt;two&lt;/strong&gt; symmetric&lt;br&gt;
reads plus the entity lookups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Get a student's profile, and &lt;strong&gt;list every course that student is enrolled in&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Get a course's metadata, and &lt;strong&gt;list every student enrolled in that course&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Look up a single enrollment edge — to update a grade or drop the course.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two list reads point in opposite directions across the same set of&lt;br&gt;
edges. A naive design serves one cheaply and forces a &lt;code&gt;Scan&lt;/code&gt; for the other — the exact&lt;br&gt;
footgun covered in &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;Query vs Scan&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The job is to make &lt;strong&gt;both&lt;/strong&gt; directions a single &lt;code&gt;Query&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the adjacency-list pattern
&lt;/h2&gt;

&lt;p&gt;DynamoDB's own guidance for relationships is the &lt;strong&gt;adjacency list&lt;/strong&gt;: model each&lt;br&gt;
relationship as an item whose partition key is one endpoint and whose sort key is the&lt;br&gt;
other.&lt;/p&gt;

&lt;p&gt;AWS documents this on the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html" rel="noopener noreferrer"&gt;Best Practices for Managing Many-to-Many Relationships&lt;/a&gt;&lt;br&gt;
page of the DynamoDB Developer Guide.&lt;/p&gt;

&lt;p&gt;Why keys and not a second table? Because the primitive DynamoDB gives you is a &lt;code&gt;Query&lt;/code&gt;&lt;br&gt;
against a single partition.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Query&lt;/code&gt; reads a contiguous range of sort-key values under one partition key in one&lt;br&gt;
billed operation — that is the only "join" the engine offers.&lt;/p&gt;

&lt;p&gt;To get a relationship that reads cheaply from &lt;em&gt;both&lt;/em&gt; sides, you duplicate the edge:&lt;br&gt;
write it once keyed by the student, then use a secondary index to project the same edge&lt;br&gt;
keyed by the course.&lt;/p&gt;

&lt;p&gt;This is the overloaded-key thinking from&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;Single-Table Design&lt;/a&gt;, applied to a relationship instead&lt;br&gt;
of a parent-child hierarchy.&lt;/p&gt;

&lt;p&gt;The shape is two stacked views of the same edge — the base table keyed by student, the&lt;br&gt;
inverted GSI keyed by course:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxuq6ks6z0bd1nsz0kz8n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxuq6ks6z0bd1nsz0kz8n.png" alt="Many-to-Many Relationships in DynamoDB" width="800" height="1413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each edge is written once on the base table and projected into the GSI with its keys&lt;br&gt;
swapped, so a &lt;code&gt;Query&lt;/code&gt; against either partition reads the relationship cheaply.&lt;/p&gt;

&lt;p&gt;The lineage goes back to the 2007 Amazon&lt;br&gt;
&lt;a href="https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf" rel="noopener noreferrer"&gt;Dynamo paper&lt;/a&gt;:&lt;br&gt;
the partition key is the unit of distribution, and single-key access is the fast path.&lt;/p&gt;

&lt;p&gt;Relationships in DynamoDB are an exercise in bending many-to-many reads into that fast&lt;br&gt;
path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Work the example: students ↔ courses
&lt;/h2&gt;

&lt;p&gt;Use one table with generic keys, &lt;code&gt;PK&lt;/code&gt; and &lt;code&gt;SK&lt;/code&gt;, and encode the entity type in the&lt;br&gt;
value. The enrollment edge is the heart of it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PK&lt;/th&gt;
&lt;th&gt;SK&lt;/th&gt;
&lt;th&gt;attributes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;STU#a91&lt;/td&gt;
&lt;td&gt;PROFILE&lt;/td&gt;
&lt;td&gt;name, year, major&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STU#a91&lt;/td&gt;
&lt;td&gt;ENROLL#CRS#math204 enrolledOn, grade&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STU#a91&lt;/td&gt;
&lt;td&gt;ENROLL#CRS#cs101&lt;/td&gt;
&lt;td&gt;enrolledOn, grade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRS#math204&lt;/td&gt;
&lt;td&gt;METADATA&lt;/td&gt;
&lt;td&gt;title, credits, term&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRS#cs101&lt;/td&gt;
&lt;td&gt;METADATA&lt;/td&gt;
&lt;td&gt;title, credits, term&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A single &lt;code&gt;Query PK = "STU#a91"&lt;/code&gt; returns the student's profile &lt;strong&gt;and&lt;/strong&gt; every enrollment&lt;br&gt;
in one read. Narrow it with &lt;code&gt;SK begins_with "ENROLL#"&lt;/code&gt; to get just the course edges.&lt;br&gt;
That solves "list a student's courses".&lt;/p&gt;

&lt;p&gt;But "list a course's students" points the other way — and the base table can't answer&lt;br&gt;
it, because the student id is in the partition key, not the sort key.&lt;/p&gt;

&lt;p&gt;Add an inverted &lt;strong&gt;global secondary index&lt;/strong&gt; that swaps the roles. Give the edge items a&lt;br&gt;
generic &lt;code&gt;GSI1PK&lt;/code&gt;/&lt;code&gt;GSI1SK&lt;/code&gt; pair holding the course on the partition side and the student&lt;br&gt;
on the sort side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PK&lt;/th&gt;
&lt;th&gt;SK&lt;/th&gt;
&lt;th&gt;GSI1PK&lt;/th&gt;
&lt;th&gt;GSI1SK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;STU#a91&lt;/td&gt;
&lt;td&gt;ENROLL#CRS#math204&lt;/td&gt;
&lt;td&gt;CRS#math204&lt;/td&gt;
&lt;td&gt;STU#a91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STU#b30&lt;/td&gt;
&lt;td&gt;ENROLL#CRS#math204&lt;/td&gt;
&lt;td&gt;CRS#math204&lt;/td&gt;
&lt;td&gt;STU#b30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STU#a91&lt;/td&gt;
&lt;td&gt;ENROLL#CRS#cs101&lt;/td&gt;
&lt;td&gt;CRS#cs101&lt;/td&gt;
&lt;td&gt;STU#a91&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now &lt;code&gt;Query GSI1 WHERE GSI1PK = "CRS#math204"&lt;/code&gt; lists every student in that course — the&lt;br&gt;
read the base table couldn't serve. One edge item, written once, answers both&lt;br&gt;
directions.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Interactive access pattern explorer: &lt;a href="https://dynotable.com/learn/many-to-many-relationships-dynamodb?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;try it on dynotable.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It has to be a GSI, not an LSI: the course partition is entirely different from the&lt;br&gt;
student partition, and an LSI shares the base table's partition key.&lt;/p&gt;

&lt;p&gt;The index spans multiple partitions, so it must be global — see&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;GSI vs LSI&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;GSIs in DynamoDB are populated asynchronously. A brand-new enrollment can take a&lt;br&gt;
moment to appear in the &lt;code&gt;CRS#…&lt;/code&gt; direction.&lt;/p&gt;

&lt;p&gt;Treat the course-roster read as &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb#eventually-consistent" rel="noopener noreferrer"&gt;eventually consistent&lt;/a&gt; — which the Developer Guide calls&lt;br&gt;
out explicitly for global secondary indexes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write and read it in DynoTable
&lt;/h2&gt;

&lt;p&gt;Writing the enrollment means setting four key attributes plus the edge's own data. The&lt;br&gt;
condition that stops a student from enrolling twice in the same course is an&lt;br&gt;
&lt;code&gt;attribute_not_exists(PK)&lt;/code&gt; guard on the composite key.&lt;/p&gt;

&lt;p&gt;That's exactly the kind of condition you can assemble visually with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; instead of&lt;br&gt;
hand-writing the &lt;code&gt;ExpressionAttributeNames&lt;/code&gt; and placeholder values.&lt;/p&gt;

&lt;p&gt;In DynoTable you point a &lt;code&gt;Query&lt;/code&gt; at &lt;code&gt;GSI1&lt;/code&gt;, set &lt;code&gt;GSI1PK = "CRS#math204"&lt;/code&gt;, and the&lt;br&gt;
roster comes back as a table you can read, sort, and edit in place — both directions of&lt;br&gt;
the relationship browsable from one schema.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F47ujml0ulyahr8etg59d.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F47ujml0ulyahr8etg59d.webp" alt="Querying the inverted GSI in DynoTable to list every student enrolled in a course." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls and next steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't store one side as a list attribute.&lt;/strong&gt; A &lt;code&gt;courseIds&lt;/code&gt; array on the student item
feels tidy until a course needs its roster, the array hits the 400 KB item ceiling, or
two enrollments race and clobber each other. Discrete edge items scale and update
independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep edge data on the edge.&lt;/strong&gt; The enrollment's &lt;code&gt;grade&lt;/code&gt; and &lt;code&gt;enrolledOn&lt;/code&gt; belong on
the edge item, not duplicated onto the student or course — there's exactly one row per
(student, course) pair to update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind GSI propagation.&lt;/strong&gt; The inverted-index direction is eventually consistent, so a
read immediately after an enrollment may lag by a fraction of a second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project only what the roster needs.&lt;/strong&gt; A &lt;code&gt;KEYS_ONLY&lt;/code&gt; or narrow projection keeps the
GSI small when the roster view only needs ids.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To go deeper on the surrounding patterns, read&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;Single-Table Design&lt;/a&gt; for overloaded keys and&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;GSI vs LSI&lt;/a&gt; for when the inverted index has to be global. And&lt;br&gt;
to start from your own relationships, the free&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;Single-Table Design tool&lt;/a&gt; turns an&lt;br&gt;
access-pattern list like "list a student's courses / list a course's students"&lt;br&gt;
into a PK/SK/GSI plan with example items.&lt;/p&gt;

&lt;p&gt;Then &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt; to model the students ↔ courses schema for real —&lt;br&gt;
write the edges, build the condition with the Expression Builder, and query both&lt;br&gt;
directions of the relationship without a single scan. And when you want the&lt;br&gt;
classic three-table &lt;code&gt;JOIN&lt;/code&gt; view anyway, &lt;a href="https://dynotable.com/learn/dynamodb-join?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=many-to-many-relationships-dynamodb" rel="noopener noreferrer"&gt;DynoTable's SQL&lt;br&gt;
Workbench&lt;/a&gt; runs it over your live tables.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>How to Model Data in DynamoDB</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sat, 29 Aug 2026 14:28:39 +0000</pubDate>
      <link>https://dev.to/dynotable/how-to-model-data-in-dynamodb-3ff6</link>
      <guid>https://dev.to/dynotable/how-to-model-data-in-dynamodb-3ff6</guid>
      <description>&lt;p&gt;In SQL you model entities and relationships first, then trust the query planner to assemble whatever you ask for later. DynamoDB inverts that. You model the &lt;strong&gt;reads you already know you'll make&lt;/strong&gt;, and the keys exist to serve them.&lt;/p&gt;

&lt;p&gt;There is no join engine and no planner picking a strategy at runtime. A &lt;code&gt;Query&lt;/code&gt; reads one partition along one key, and that is the entire performance contract. So you design keys for known access patterns, not for a tidy schema.&lt;/p&gt;

&lt;p&gt;AWS says it plainly in its &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-general-nosql-design.html" rel="noopener noreferrer"&gt;best-practices guide&lt;/a&gt;: "you shouldn't start designing your schema until you know the questions it will need to answer."&lt;/p&gt;

&lt;p&gt;This guide walks the whole process on one domain: a &lt;strong&gt;multiplayer game leaderboard&lt;/strong&gt; tracking players, the matches they play, and their per-season ranking. We go from a list of questions to a working key schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you model data in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;Model the reads first, not the tables. List every query the app makes, then design a &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt; and &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb#sort-key" rel="noopener noreferrer"&gt;sort key&lt;/a&gt; so each question resolves to a single &lt;code&gt;Query&lt;/code&gt; or &lt;code&gt;GetItem&lt;/code&gt;. Co-locate items that are read together, range over values in the sort key, and add a GSI for any access pattern the base table can't serve.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;List the reads first, not the tables.&lt;/strong&gt; The questions are the spec; the nouns are a distraction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each question must be one &lt;code&gt;Query&lt;/code&gt; or &lt;code&gt;GetItem&lt;/code&gt;.&lt;/strong&gt; If a question needs a &lt;code&gt;Scan&lt;/code&gt;, the model is wrong.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Co-located items share a &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt;; anything you range over goes in the &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb#sort-key" rel="noopener noreferrer"&gt;sort key&lt;/a&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A question the base table can't answer gets a &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb#gsi" rel="noopener noreferrer"&gt;GSI&lt;/a&gt;&lt;/strong&gt; — never a &lt;code&gt;Scan&lt;/code&gt; with a filter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1 — Frame the problem as questions, not tables
&lt;/h2&gt;

&lt;p&gt;Resist the urge to draw &lt;code&gt;players&lt;/code&gt;, &lt;code&gt;matches&lt;/code&gt;, and &lt;code&gt;scores&lt;/code&gt; tables. That instinct is the SQL habit, and here it's wrong. Instead write down every read the app actually performs. For our leaderboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fetch one player's profile by id.&lt;/li&gt;
&lt;li&gt;List a player's recent matches, newest first.&lt;/li&gt;
&lt;li&gt;Show the top N players for a given season, ranked by rating.&lt;/li&gt;
&lt;li&gt;Look up a player by their public handle (e.g. for a profile URL).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These four questions — not the nouns — are the spec. Each one must resolve to a single &lt;code&gt;Query&lt;/code&gt; (or &lt;code&gt;GetItem&lt;/code&gt;), because that's the only access shape DynamoDB serves cheaply at scale.&lt;/p&gt;

&lt;p&gt;If a question can only be answered by scanning the table, the model is wrong, and you'll feel it in latency and cost — see &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb" rel="noopener noreferrer"&gt;Query vs Scan&lt;/a&gt; for why a &lt;code&gt;Scan&lt;/code&gt; is the footgun to avoid.&lt;/p&gt;

&lt;p&gt;The whole method is a short, ordered pipeline you run once per domain:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyc9koo2sq9j6e8b488wk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyc9koo2sq9j6e8b488wk.png" alt="How to Model Data in DynamoDB" width="800" height="1530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each step below maps onto one box: list, enumerate, design keys, add indexes for the rest, then validate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Understand the primitives you're modeling with
&lt;/h2&gt;

&lt;p&gt;A table has a &lt;strong&gt;partition key&lt;/strong&gt; (PK) that picks which physical partition an item lives on, and an optional &lt;strong&gt;sort key&lt;/strong&gt; (SK) that orders items &lt;em&gt;within&lt;/em&gt; that partition.&lt;/p&gt;

&lt;p&gt;The AWS &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html" rel="noopener noreferrer"&gt;core-components docs&lt;/a&gt; call the pair the item's primary key. A &lt;code&gt;Query&lt;/code&gt; always targets exactly one PK value and can range-scan or filter the SK — that's the whole toolkit.&lt;/p&gt;

&lt;p&gt;This single-partition design is what lets DynamoDB deliver the predictable, low-latency, horizontally partitioned reads first described in the 2007 Amazon Dynamo paper.&lt;/p&gt;

&lt;p&gt;Two consequences drive every decision below:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Items that are read together should share a partition key&lt;/strong&gt; so one &lt;code&gt;Query&lt;/code&gt; returns them in a single billed request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything you want to range over&lt;/strong&gt; (recent matches, top ratings) must live in the &lt;strong&gt;sort key&lt;/strong&gt;, because that's the only attribute &lt;code&gt;Query&lt;/code&gt; can order and bound.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a question needs a &lt;em&gt;different&lt;/em&gt; access shape than the base table provides, you add a &lt;strong&gt;Global Secondary Index&lt;/strong&gt; — a re-projection of the table under a different PK/SK.&lt;/p&gt;

&lt;p&gt;(For GSI versus Local Secondary Index, see &lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb" rel="noopener noreferrer"&gt;GSI vs LSI&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Design the keys, one question at a time
&lt;/h2&gt;

&lt;p&gt;We use a single table with generic, overloaded key attributes — the &lt;a href="https://dynotable.com/learn/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb" rel="noopener noreferrer"&gt;single-table approach&lt;/a&gt; — because a player and their matches are read together.&lt;/p&gt;

&lt;p&gt;Invent your own prefixes; here &lt;code&gt;PLAYER#&lt;/code&gt;, &lt;code&gt;MATCH#&lt;/code&gt;, and &lt;code&gt;SEASON#&lt;/code&gt; tag the entity type inside otherwise-generic keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Questions 1 and 2&lt;/strong&gt; (profile + recent matches) share a partition, so both hang off the same PK:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;partitionId&lt;/th&gt;
&lt;th&gt;rangeId&lt;/th&gt;
&lt;th&gt;attributes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PLAYER#u8231&lt;/td&gt;
&lt;td&gt;PROFILE&lt;/td&gt;
&lt;td&gt;handle, region, createdAt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PLAYER#u8231&lt;/td&gt;
&lt;td&gt;MATCH#2026-06-23T14&lt;/td&gt;
&lt;td&gt;result=win, ratingDelta=+18, mapId&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PLAYER#u8231&lt;/td&gt;
&lt;td&gt;MATCH#2026-06-23T11&lt;/td&gt;
&lt;td&gt;result=loss, ratingDelta=-15, mapId&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;Query partitionId = "PLAYER#u8231"&lt;/code&gt; returns the profile and every match in one read. For the profile alone, &lt;code&gt;GetItem&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For recent matches, &lt;code&gt;rangeId begins_with "MATCH#"&lt;/code&gt; with &lt;code&gt;ScanIndexForward = false&lt;/code&gt; walks them newest-first — the timestamp in the sort key does the ordering for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Questions 3 and 4&lt;/strong&gt; can't be answered from that partition — they pivot on season rank and on handle, neither of which is the base PK. Each gets a GSI.&lt;/p&gt;

&lt;p&gt;We add two pairs of generic index attributes — &lt;code&gt;seasonPartition&lt;/code&gt; / &lt;code&gt;seasonSort&lt;/code&gt; for the rank index and &lt;code&gt;handlePartition&lt;/code&gt; / &lt;code&gt;handleSort&lt;/code&gt; for the handle index — populated on the &lt;strong&gt;same&lt;/strong&gt; profile item (the one written in Step 3, now shown with its index attributes filled in):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;partitionId&lt;/th&gt;
&lt;th&gt;rangeId&lt;/th&gt;
&lt;th&gt;seasonPartition&lt;/th&gt;
&lt;th&gt;seasonSort&lt;/th&gt;
&lt;th&gt;handlePartition&lt;/th&gt;
&lt;th&gt;handleSort&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PLAYER#u8231&lt;/td&gt;
&lt;td&gt;PROFILE&lt;/td&gt;
&lt;td&gt;SEASON#2026-Q2&lt;/td&gt;
&lt;td&gt;RATING#1842&lt;/td&gt;
&lt;td&gt;HANDLE#nighthawk&lt;/td&gt;
&lt;td&gt;PLAYER#u8231&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now &lt;code&gt;Query&lt;/code&gt; the season index &lt;code&gt;WHERE seasonPartition = "SEASON#2026-Q2"&lt;/code&gt; with &lt;code&gt;ScanIndexForward = false&lt;/code&gt; returns players ranked by rating — that's the leaderboard.&lt;/p&gt;

&lt;p&gt;A second index keyed on &lt;code&gt;handlePartition = "HANDLE#…"&lt;/code&gt; resolves a public handle to a player id in one read. One physical table, four single-&lt;code&gt;Query&lt;/code&gt; access patterns.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb#zero-padding" rel="noopener noreferrer"&gt;zero-padding&lt;/a&gt; note on &lt;code&gt;RATING#1842&lt;/code&gt;: DynamoDB sorts sort keys &lt;strong&gt;lexicographically&lt;/strong&gt;, not numerically, so a rating must be zero-padded to a fixed width (&lt;code&gt;RATING#01842&lt;/code&gt;) or &lt;code&gt;9&lt;/code&gt; would sort after &lt;code&gt;1000&lt;/code&gt;. This is a classic modeling gotcha worth getting right up front.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 4 — Validate the model in DynoTable
&lt;/h2&gt;

&lt;p&gt;A key schema only earns trust when you watch a real &lt;code&gt;Query&lt;/code&gt; return exactly the items you expected and nothing more.&lt;/p&gt;

&lt;p&gt;Open the table in DynoTable, run the leaderboard query against the season index, and confirm the partition comes back ranked and bounded — no &lt;code&gt;Scan&lt;/code&gt;, no client-side sorting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpsynk27w6ycsmuoihvco.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpsynk27w6ycsmuoihvco.webp" alt="Running the season-leaderboard Query against the GSI in DynoTable and inspecting the ranked result." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you build the condition expressions for these queries — the &lt;code&gt;begins_with&lt;/code&gt;, the &lt;code&gt;seasonPartition = :p&lt;/code&gt;, the placeholder &lt;code&gt;:p&lt;/code&gt; binding — let the &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; do it.&lt;/p&gt;

&lt;p&gt;It generates the &lt;code&gt;KeyConditionExpression&lt;/code&gt;, the &lt;code&gt;ExpressionAttributeNames&lt;/code&gt;, and the &lt;code&gt;ExpressionAttributeValues&lt;/code&gt;, so a reserved word like &lt;code&gt;result&lt;/code&gt; or a typo'd placeholder never silently breaks a read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Pitfalls and next steps
&lt;/h2&gt;

&lt;p&gt;A few traps to check before you ship the model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't model relationships you never read together.&lt;/strong&gt; A GSI per question is cheap; a wasted GSI is recurring cost. Add indexes from the question list, not speculatively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch partition heat.&lt;/strong&gt; If one PK (a celebrity player, a single hot season) absorbs most traffic, that partition can throttle. Spread writes with a suffix shard when a key is provably hot — AWS covers this under &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-design.html" rel="noopener noreferrer"&gt;partition-key design&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-pad and ISO-8601 everything numeric or temporal in a sort key&lt;/strong&gt;, so lexicographic ordering matches the order you mean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new question = a new key or index, never a &lt;code&gt;Scan&lt;/code&gt;.&lt;/strong&gt; When a genuinely new access pattern appears later, extend the keys; don't paper over it with a filter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model the questions first, design keys so each is one &lt;code&gt;Query&lt;/code&gt;, then prove it.&lt;/p&gt;

&lt;p&gt;For a head start on the middle step, the free &lt;a href="https://dynotable.com/tools/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb" rel="noopener noreferrer"&gt;Single-Table Design tool&lt;/a&gt; turns a list of access patterns like this one into a PK/SK/GSI plan, with example items and cost hints.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb" rel="noopener noreferrer"&gt;Try DynoTable&lt;/a&gt; to browse your table, run these queries against the base table and the GSIs side by side, and watch the access patterns you designed return exactly what you planned. And for the question you &lt;em&gt;didn't&lt;/em&gt; model, its &lt;a href="https://dynotable.com/learn/sql-for-dynamodb?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=how-to-model-data-dynamodb" rel="noopener noreferrer"&gt;SQL Workbench&lt;/a&gt; runs real &lt;code&gt;JOIN&lt;/code&gt;s, &lt;code&gt;GROUP BY&lt;/code&gt;, and aggregates client-side.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
  </channel>
</rss>
