<?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 Condition Expressions</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sun, 26 Jul 2026 06:56:22 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-condition-expressions-19nm</link>
      <guid>https://dev.to/dynotable/dynamodb-condition-expressions-19nm</guid>
      <description>&lt;p&gt;A condition expression is a predicate DynamoDB evaluates on the existing item&lt;br&gt;
&lt;strong&gt;before&lt;/strong&gt; it commits your write. If the predicate is false, the write is&lt;br&gt;
rejected and nothing changes. It is the closest thing DynamoDB has to a&lt;br&gt;
&lt;code&gt;WHERE&lt;/code&gt; clause on a write — and the only safe way to enforce an invariant.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do DynamoDB condition expressions work?
&lt;/h2&gt;

&lt;p&gt;A condition expression is a predicate DynamoDB evaluates server-side against the current item before committing a write. If it's true, the write proceeds; if false, the write is rejected with &lt;code&gt;ConditionalCheckFailedException&lt;/code&gt; and nothing changes. It folds the check and the mutation into one atomic operation, so concurrent callers can't race a stale read.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a guard, not a filter.&lt;/strong&gt; &lt;code&gt;ConditionExpression&lt;/code&gt; runs server-side on the
current item; a false result fails the write with &lt;code&gt;ConditionalCheckFailedException&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It replaces read-then-write.&lt;/strong&gt; No &lt;code&gt;SELECT&lt;/code&gt; then &lt;code&gt;UPDATE&lt;/code&gt; round trip — the
check and the mutation are one atomic operation, so two callers can't race.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's free to reject, not free to run.&lt;/strong&gt; A failed conditional write still
consumes write capacity. A rejected write bills WCUs for the size of the
existing item it was checked against (minimum 1) — a failed create-if-absent
costs 1 WCU.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coming from SQL, you'd read the row, check it in app code, then update. In&lt;br&gt;
DynamoDB that gap between read and write is a data-corruption bug waiting for a&lt;br&gt;
concurrent caller. The condition expression closes the gap.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where they apply
&lt;/h2&gt;

&lt;p&gt;You attach a &lt;code&gt;ConditionExpression&lt;/code&gt; to &lt;code&gt;PutItem&lt;/code&gt;, &lt;code&gt;UpdateItem&lt;/code&gt;, &lt;code&gt;DeleteItem&lt;/code&gt;, and&lt;br&gt;
each action inside &lt;code&gt;TransactWriteItems&lt;/code&gt;. It is &lt;strong&gt;not&lt;/strong&gt; part of &lt;code&gt;Query&lt;/code&gt; or &lt;code&gt;Scan&lt;/code&gt;&lt;br&gt;
— those use &lt;code&gt;FilterExpression&lt;/code&gt;, which is a different thing on the read path.&lt;/p&gt;

&lt;p&gt;That distinction trips people up, so be precise:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ConditionExpression&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;FilterExpression&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Path&lt;/td&gt;
&lt;td&gt;Writes (&lt;code&gt;Put&lt;/code&gt;/&lt;code&gt;Update&lt;/code&gt;/&lt;code&gt;Delete&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Reads (&lt;code&gt;Query&lt;/code&gt;/&lt;code&gt;Scan&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Effect on failure&lt;/td&gt;
&lt;td&gt;Rejects the whole write&lt;/td&gt;
&lt;td&gt;Drops the item from results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sees&lt;/td&gt;
&lt;td&gt;The current item, pre-write&lt;/td&gt;
&lt;td&gt;Each candidate item, post-read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Failed write still bills&lt;/td&gt;
&lt;td&gt;Filtered items are still billed for the read&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both run server-side. The difference is what "false" does: a condition aborts a&lt;br&gt;
mutation; a filter just hides a row you already paid to read.&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html" rel="noopener noreferrer"&gt;AWS: Condition Expressions&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;
  
  
  The functions you'll actually use
&lt;/h2&gt;

&lt;p&gt;The condition language is small. The workhorses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;attribute_exists(path)&lt;/code&gt; / &lt;code&gt;attribute_not_exists(path)&lt;/code&gt; — does this &lt;a href="https://dynotable.com/docs/glossary#attribute" rel="noopener noreferrer"&gt;attribute&lt;/a&gt;
exist on the item? The classic idiom for "create only if absent" / "update
only if present".&lt;/li&gt;
&lt;li&gt;Comparators — &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;lt;=&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;gt;=&lt;/code&gt; — against a value or another
attribute.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;attribute_type&lt;/code&gt;, &lt;code&gt;begins_with&lt;/code&gt;, &lt;code&gt;contains&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt; — type and string/set checks.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BETWEEN … AND …&lt;/code&gt;, &lt;code&gt;IN (…)&lt;/code&gt; — range and membership.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AND&lt;/code&gt;, &lt;code&gt;OR&lt;/code&gt;, &lt;code&gt;NOT&lt;/code&gt;, parentheses — to combine the above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;attribute_not_exists&lt;/code&gt; on the &lt;a href="https://dynotable.com/docs/glossary#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt; is the canonical way to make&lt;br&gt;
&lt;code&gt;PutItem&lt;/code&gt; behave like an insert that won't clobber an existing item — DynamoDB&lt;br&gt;
has no separate "insert" op, so the condition &lt;em&gt;is&lt;/em&gt; the insert semantics.&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.OperatorsAndFunctions.html" rel="noopener noreferrer"&gt;AWS: Comparison Operator and Function Reference&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;
  
  
  A worked example: guarding a ledger against overdraft
&lt;/h2&gt;

&lt;p&gt;Take a banking ledger. Each account is one item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PK = "ACCT#a7f3"
SK = "BALANCE"
clearedCents = 50000
holdCents    = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The invariant: a debit must never push the available balance below zero, and you&lt;br&gt;
must never debit an account that doesn't exist. Two rules, both enforceable in&lt;br&gt;
the write itself.&lt;/p&gt;
&lt;h3&gt;
  
  
  The wrong way (the footgun)
&lt;/h3&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;GetItem ACCT#a7f3 / BALANCE     → clearedCents = 50000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;if (50000 &amp;gt;= 30000) ...         ← app-side check&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UpdateItem  SET clearedCents = 20000&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;p&gt;Between the &lt;code&gt;GetItem&lt;/code&gt; and the &lt;code&gt;UpdateItem&lt;/code&gt;, a second debit can read the same&lt;br&gt;
&lt;code&gt;50000&lt;/code&gt;, pass its own check, and write too. Both succeed; the account goes&lt;br&gt;
negative. This is a read-modify-write race, and no amount of app-side validation&lt;br&gt;
fixes it — the check and the write are separate operations.&lt;/p&gt;
&lt;h3&gt;
  
  
  The right way
&lt;/h3&gt;

&lt;p&gt;Fold the check into the write. Debit 30000 cents, conditional on the account&lt;br&gt;
existing &lt;strong&gt;and&lt;/strong&gt; holding enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UpdateItem  ACCT#a7f3 / BALANCE
  SET clearedCents = clearedCents - :amt
  ConditionExpression:
    attribute_exists(PK) AND clearedCents &amp;gt;= :amt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with &lt;code&gt;:amt = 30000&lt;/code&gt;. If the balance is too low, or the item was never created,&lt;br&gt;
DynamoDB rejects the write with&lt;br&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/conditionalcheckfailedexception" rel="noopener noreferrer"&gt;&lt;code&gt;ConditionalCheckFailedException&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
and the balance&lt;br&gt;
is untouched. The concurrent debit either sees the original balance and is&lt;br&gt;
checked against it, or sees the updated one — never a stale read it acted on.&lt;/p&gt;

&lt;p&gt;You can build and copy the exact expression — names, values, and all — with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB expression builder&lt;/a&gt; instead of&lt;br&gt;
hand-assembling the &lt;code&gt;ExpressionAttributeValues&lt;/code&gt; map.&lt;/p&gt;

&lt;p&gt;Try it right here — this builder is preset to a guarded &lt;code&gt;PutItem&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;attribute_not_exists&lt;/code&gt;) so you can read the generated &lt;code&gt;ConditionExpression&lt;/code&gt;:&lt;/p&gt;



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

&lt;p&gt;&lt;code&gt;clearedCents &amp;gt;= :amt&lt;/code&gt; compares the &lt;em&gt;stored&lt;/em&gt; value, not the value you read&lt;br&gt;
earlier. That's the whole point — the comparison happens after any concurrent&lt;br&gt;
write has landed, so the guard is always evaluated against current truth.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Inspecting the guard in DynoTable
&lt;/h3&gt;

&lt;p&gt;When a conditional write fails, you want to see the item's real state, not guess&lt;br&gt;
at it. Pull the account item up and read &lt;code&gt;clearedCents&lt;/code&gt; directly.&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%2Fsv4z78tyqqh1nikfa8x1.webp" 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%2Fsv4z78tyqqh1nikfa8x1.webp" alt="The ledger collection in DynoTable — the BALANCE item shows clearedCents above the account's transaction items." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Read the rejection, don't retry blindly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ConditionalCheckFailedException&lt;/code&gt; is not a transient error — retrying the same&lt;br&gt;
write changes nothing. It means a business rule fired: insufficient funds,&lt;br&gt;
duplicate create, stale version. Surface it as a domain outcome, not an infra&lt;br&gt;
blip.&lt;/p&gt;

&lt;p&gt;Two things make failures debuggable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReturnValuesOnConditionCheckFailure: ALL_OLD&lt;/code&gt;&lt;/strong&gt; — DynamoDB returns the
current item alongside the failure, so you can show "balance was 20000, you
asked for 30000" without a second read.
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithItems.html" rel="noopener noreferrer"&gt;AWS: Working with Items&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinguishing the two failure reasons.&lt;/strong&gt; &lt;code&gt;attribute_exists(PK) AND
clearedCents &amp;gt;= :amt&lt;/code&gt; collapses "no account" and "no funds" into one
exception. If callers need to tell them apart, split into two writes or inspect
the returned item.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Optimistic locking is the same trick
&lt;/h2&gt;

&lt;p&gt;The version-number pattern is just a condition expression wearing a different&lt;br&gt;
hat. Store a &lt;code&gt;version&lt;/code&gt; attribute; every write asserts the version you read and&lt;br&gt;
bumps it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UpdateItem  ACCT#a7f3 / BALANCE
  SET clearedCents = :new, version = :next
  ConditionExpression: version = :seen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If another writer moved first, &lt;code&gt;version = :seen&lt;/code&gt; is false, the write is rejected,&lt;br&gt;
and you re-read and retry. This is how DynamoDB does concurrency control without&lt;br&gt;
locks — assert what you saw, fail if it moved. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBMapper.OptimisticLocking.html" rel="noopener noreferrer"&gt;AWS: Optimistic Locking with&lt;br&gt;
Version Number&lt;/a&gt;)&lt;br&gt;
DynoTable's &lt;a href="https://dynotable.com/docs/staging" rel="noopener noreferrer"&gt;staging area&lt;/a&gt; runs this pattern for you — a&lt;br&gt;
concurrent edit surfaces as a conflict to resolve, not a lost write.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Names that collide with reserved words.&lt;/strong&gt; &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, and ~570
others are reserved. Alias them with &lt;code&gt;ExpressionAttributeNames&lt;/code&gt; (&lt;code&gt;#s = status&lt;/code&gt;)
or the request is rejected with a ValidationException ('Attribute name is a
reserved keyword'). The
&lt;a href="https://dynotable.com/tools/dynamodb-reserved-words-checker" rel="noopener noreferrer"&gt;reserved-words checker&lt;/a&gt; takes your
attribute names and hands back the alias map ready to paste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A condition can't reference another item.&lt;/strong&gt; It only sees the item being
written. Cross-item invariants need &lt;code&gt;TransactWriteItems&lt;/code&gt; with a per-action
&lt;code&gt;ConditionExpression&lt;/code&gt;, or a &lt;code&gt;ConditionCheck&lt;/code&gt; against a sentinel item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failed writes still cost WCUs.&lt;/strong&gt; A guard that rejects 90% of the time still
bills for those rejections. Cheap insurance, but not free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For modeling the keys these guards run against, see&lt;br&gt;
&lt;a href="https://dynotable.com/learn/single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://dynotable.com/learn/query-vs-scan" rel="noopener noreferrer"&gt;Query vs Scan&lt;/a&gt;. When you're ready to issue conditional&lt;br&gt;
writes against real data, &lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt; and run them against&lt;br&gt;
your own tables.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>Composite Sort Keys in DynamoDB</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sun, 26 Jul 2026 06:56:21 +0000</pubDate>
      <link>https://dev.to/dynotable/composite-sort-keys-in-dynamodb-ame</link>
      <guid>https://dev.to/dynotable/composite-sort-keys-in-dynamodb-ame</guid>
      <description>&lt;p&gt;A &lt;strong&gt;&lt;a href="https://dynotable.com/docs/glossary#composite-key" rel="noopener noreferrer"&gt;composite primary key&lt;/a&gt;&lt;/strong&gt; is a partition key plus a sort key. The trick that&lt;br&gt;
makes it powerful is what you put &lt;em&gt;in&lt;/em&gt; the sort key: encode a hierarchy as one&lt;br&gt;
delimited string, and a single &lt;code&gt;Query&lt;/code&gt; reads an entire subtree in sort order — no&lt;br&gt;
joins, no recursion, no second round-trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do composite sort keys work in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;A composite sort key packs a hierarchy into one delimited string — &lt;code&gt;root/photos/2026/&lt;/code&gt; — that DynamoDB stores in UTF-8 byte order. Because the layout already matches the tree, a single &lt;code&gt;Query&lt;/code&gt; with &lt;code&gt;begins_with(SK, "root/photos/")&lt;/code&gt; reads an entire subtree in path order. No joins, no recursion, no second round-trip — just a prefix scan over a contiguous &lt;a href="https://dynotable.com/docs/glossary#item-collection" rel="noopener noreferrer"&gt;slice&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The sort key is a sortable string, not just an ID.&lt;/strong&gt; Pack a path into it —
&lt;code&gt;root/photos/2026/&lt;/code&gt; — and DynamoDB stores the partition's items in UTF-8 byte
order automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A delimiter turns prefix matches into subtree reads.&lt;/strong&gt; &lt;code&gt;begins_with(SK, "root/photos/")&lt;/code&gt; returns every descendant of that folder in one query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort keys support range conditions, not arbitrary filters.&lt;/strong&gt; You get
&lt;code&gt;begins_with&lt;/code&gt;, &lt;code&gt;between&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt; — design the key so the read you need is a
prefix or a range, not a &lt;code&gt;Scan&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The delimiter is load-bearing.&lt;/strong&gt; Pick one that can't appear in a path segment,
or two unrelated branches collide.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the sort key is the whole game
&lt;/h2&gt;

&lt;p&gt;Coming from SQL, you'd model a folder tree with a &lt;code&gt;parent_id&lt;/code&gt; self-join and walk&lt;br&gt;
it recursively — one query per level. In DynamoDB that's an N+1 footgun against a&lt;br&gt;
key-value store that has no joins.&lt;/p&gt;

&lt;p&gt;DynamoDB stores every item under a partition key sorted by its sort key, in UTF-8&lt;br&gt;
byte order for strings (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.KeyConditionExpressions.html" rel="noopener noreferrer"&gt;AWS: Query key conditions&lt;/a&gt;).&lt;br&gt;
So if your sort key &lt;em&gt;is&lt;/em&gt; the path, the physical layout already matches the tree.&lt;br&gt;
A read becomes a prefix scan over a contiguous slice — not a graph walk.&lt;/p&gt;

&lt;p&gt;That's the shift: the sort key isn't an identifier you match exactly. It's a&lt;br&gt;
sortable address. Design it and the query falls out for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model a file-system tree
&lt;/h2&gt;

&lt;p&gt;Say you're storing per-account file trees. One drive per account is the natural&lt;br&gt;
partition; the path inside it is the sort key.&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;node_type&lt;/th&gt;
&lt;th&gt;bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DRIVE#a91&lt;/td&gt;
&lt;td&gt;root/&lt;/td&gt;
&lt;td&gt;folder&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DRIVE#a91&lt;/td&gt;
&lt;td&gt;root/docs/&lt;/td&gt;
&lt;td&gt;folder&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DRIVE#a91&lt;/td&gt;
&lt;td&gt;root/docs/taxes.pdf&lt;/td&gt;
&lt;td&gt;file&lt;/td&gt;
&lt;td&gt;88210&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DRIVE#a91&lt;/td&gt;
&lt;td&gt;root/photos/&lt;/td&gt;
&lt;td&gt;folder&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DRIVE#a91&lt;/td&gt;
&lt;td&gt;root/photos/2026/&lt;/td&gt;
&lt;td&gt;folder&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DRIVE#a91&lt;/td&gt;
&lt;td&gt;root/photos/2026/beach.jpg&lt;/td&gt;
&lt;td&gt;file&lt;/td&gt;
&lt;td&gt;284910&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DRIVE#a91&lt;/td&gt;
&lt;td&gt;root/photos/2026/sunset.jpg&lt;/td&gt;
&lt;td&gt;file&lt;/td&gt;
&lt;td&gt;512004&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two original conventions doing the work here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PK = DRIVE#&amp;lt;account&amp;gt;&lt;/code&gt;&lt;/strong&gt; keeps one account's whole tree in a single &lt;a href="https://dynotable.com/docs/glossary#item-collection" rel="noopener noreferrer"&gt;item collection&lt;/a&gt;, so any subtree read is a single-partition &lt;code&gt;Query&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SK&lt;/code&gt;&lt;/strong&gt; is the full path with a &lt;strong&gt;trailing &lt;code&gt;/&lt;/code&gt; on folders&lt;/strong&gt;. The trailing slash
is deliberate — it makes a folder sort &lt;em&gt;before&lt;/em&gt; its own children and keeps
&lt;code&gt;root/photos/&lt;/code&gt; distinct from a sibling file named &lt;code&gt;root/photos&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Read a subtree in one query
&lt;/h2&gt;

&lt;p&gt;List everything under &lt;code&gt;root/photos/&lt;/code&gt; — folder, subfolders, and files, recursively:&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;Query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KeyConditionExpression = PK = :drive AND begins_with(SK, :prefix)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:drive   = "DRIVE#a91"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:prefix  = "root/photos/"&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;p&gt;That returns &lt;code&gt;root/photos/&lt;/code&gt;, &lt;code&gt;root/photos/2026/&lt;/code&gt;, &lt;code&gt;beach.jpg&lt;/code&gt;, and &lt;code&gt;sunset.jpg&lt;/code&gt; —&lt;br&gt;
in path order, in one billed read. You pay only for the items in that slice, not&lt;br&gt;
the whole drive.&lt;/p&gt;

&lt;p&gt;In DynoTable, you run exactly this &lt;code&gt;begins_with&lt;/code&gt; query against the path sort key and the&lt;br&gt;
folder plus its descendants come back in path order — no placeholder syntax to hand-write.&lt;/p&gt;

&lt;p&gt;Need the raw &lt;code&gt;KeyConditionExpression&lt;/code&gt; (names, values, and &lt;code&gt;begins_with&lt;/code&gt;) for your own&lt;br&gt;
code? Build and copy it in the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&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%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fck63adc3d2xbt4hgt07f.webp" 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%2Fck63adc3d2xbt4hgt07f.webp" alt="Running a begins_with query on the path sort key in DynoTable, returning a folder and its descendants in path order." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  List one level, not the whole subtree
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;begins_with&lt;/code&gt; gives you the &lt;em&gt;recursive&lt;/em&gt; read. For a non-recursive directory&lt;br&gt;
listing — the immediate children of &lt;code&gt;root/photos/&lt;/code&gt; and nothing deeper — store a&lt;br&gt;
&lt;strong&gt;depth&lt;/strong&gt; attribute and add a sort-key range plus a filter, or split the path into&lt;br&gt;
a &lt;code&gt;parent&lt;/code&gt; GSI. The simplest version: keep a &lt;code&gt;parent&lt;/code&gt; attribute (&lt;code&gt;root/photos/&lt;/code&gt;)&lt;br&gt;
and a GSI keyed on it.&lt;/p&gt;

&lt;p&gt;The point: a sort key answers &lt;strong&gt;prefix&lt;/strong&gt; and &lt;strong&gt;range&lt;/strong&gt; questions cheaply. "Direct&lt;br&gt;
children only" is a different question — model it explicitly rather than hoping a&lt;br&gt;
&lt;code&gt;FilterExpression&lt;/code&gt; makes it efficient. A filter runs &lt;em&gt;after&lt;/em&gt; the read and you pay&lt;br&gt;
for every item it discards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the delimiter carefully
&lt;/h2&gt;

&lt;p&gt;The delimiter is part of your data contract. Two rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It must never appear inside a path segment.&lt;/strong&gt; If filenames can contain &lt;code&gt;/&lt;/code&gt;,
&lt;code&gt;/&lt;/code&gt; is the wrong delimiter — a file named &lt;code&gt;a/b&lt;/code&gt; is indistinguishable from a
folder &lt;code&gt;a&lt;/code&gt; holding &lt;code&gt;b&lt;/code&gt;. Pick a reserved byte (some teams use &lt;code&gt;#&lt;/code&gt; or a control
char) and forbid it in segments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the sort order at boundaries.&lt;/strong&gt; &lt;code&gt;/&lt;/code&gt; (0x2F) sorts before digits and
letters, which is usually what you want for tree order. Change the delimiter and
you change the ordering — verify it against real data.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;A composite sort key is one immutable string. To "move" a folder you must rewrite&lt;br&gt;
the &lt;code&gt;SK&lt;/code&gt; of every descendant — there's no rename-in-place. Heavy-move workloads&lt;br&gt;
may want indirection (a stable node ID in the key, path in a GSI) instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Composite sort key vs. a separate sort attribute
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Composite sort key (&lt;code&gt;root/photos/2026/x&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Plain ID sort key + &lt;code&gt;parent&lt;/code&gt; attribute&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subtree read&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;begins_with&lt;/code&gt; query&lt;/td&gt;
&lt;td&gt;Recursive queries (N+1) or a GSI walk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordering&lt;/td&gt;
&lt;td&gt;Path order, free&lt;/td&gt;
&lt;td&gt;Must add an explicit sort attribute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Move / rename&lt;/td&gt;
&lt;td&gt;Rewrite all descendants&lt;/td&gt;
&lt;td&gt;Update one &lt;code&gt;parent&lt;/code&gt; pointer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct-children list&lt;/td&gt;
&lt;td&gt;Needs depth attr or GSI&lt;/td&gt;
&lt;td&gt;Natural (&lt;code&gt;parent = x&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Composite keys win when reads are &lt;strong&gt;subtree-shaped and ordering matters&lt;/strong&gt;; the&lt;br&gt;
flat-ID model wins when the tree mutates constantly. Most read-heavy hierarchies —&lt;br&gt;
file trees, category trees, org charts — lean composite.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't over-stuff the key.&lt;/strong&gt; Everything you encode is immutable and indexed by
prefix only. Attributes you query by equality belong in their own fields or a
GSI, not jammed into the sort key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sort key can't do arbitrary &lt;code&gt;WHERE&lt;/code&gt;.&lt;/strong&gt; Only &lt;code&gt;begins_with&lt;/code&gt;, &lt;code&gt;between&lt;/code&gt;, and
comparisons. If you find yourself reaching for a &lt;code&gt;FilterExpression&lt;/code&gt;, you've
probably modeled the key wrong — see &lt;a href="https://dynotable.com/learn/query-vs-scan" rel="noopener noreferrer"&gt;Query vs. Scan&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Going deeper on key design&lt;/strong&gt; lives in
&lt;a href="https://dynotable.com/learn/single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;; for when a subtree read
needs an index instead of the base table, see &lt;a href="https://dynotable.com/learn/gsi-vs-lsi" rel="noopener noreferrer"&gt;GSI vs. LSI&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build the &lt;code&gt;begins_with&lt;/code&gt; key condition with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;Expression Builder&lt;/a&gt;, then&lt;br&gt;
&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt; to run these prefix queries against your own&lt;br&gt;
tables and watch a subtree come back in path order. (And the self-&lt;code&gt;JOIN&lt;/code&gt; you&lt;br&gt;
left behind in SQL? &lt;a href="https://dynotable.com/learn/dynamodb-join" rel="noopener noreferrer"&gt;DynoTable's SQL Workbench&lt;/a&gt; still runs&lt;br&gt;
it when you need it.)&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Composite Primary Key</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sun, 26 Jul 2026 06:47:39 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-composite-primary-key-1543</link>
      <guid>https://dev.to/dynotable/dynamodb-composite-primary-key-1543</guid>
      <description>&lt;p&gt;A &lt;strong&gt;composite primary key&lt;/strong&gt; is two attributes: a &lt;strong&gt;partition key&lt;/strong&gt; and a&lt;br&gt;
&lt;strong&gt;sort key&lt;/strong&gt;. The partition key decides where an item lives; the sort key&lt;br&gt;
orders items inside that partition.&lt;/p&gt;

&lt;p&gt;Coming from SQL, think of it less as a unique &lt;code&gt;id&lt;/code&gt; column and more as&lt;br&gt;
&lt;code&gt;GROUP BY partition, ORDER BY sort&lt;/code&gt; baked into the table itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a DynamoDB composite primary key?
&lt;/h2&gt;

&lt;p&gt;A DynamoDB composite primary key combines two attributes: a partition key and a&lt;br&gt;
sort key. The partition key decides which physical partition an item lives in;&lt;br&gt;
the sort key orders items inside that partition. Together they form the item's&lt;br&gt;
unique identity and let a single &lt;code&gt;Query&lt;/code&gt; return a sorted range instead of one&lt;br&gt;
item.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two parts, two jobs.&lt;/strong&gt; The partition key routes the item to a physical
partition; the sort key orders every item that shares that partition key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uniqueness is the pair.&lt;/strong&gt; Two items can share a partition key value as long
as their sort keys differ — that's how one partition holds many rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sort key is the whole point.&lt;/strong&gt; It's what lets a &lt;code&gt;Query&lt;/code&gt; return a range
(&lt;code&gt;&amp;gt;=&lt;/code&gt;, &lt;code&gt;between&lt;/code&gt;, &lt;code&gt;begins_with&lt;/code&gt;) instead of one item, with no &lt;code&gt;Scan&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys must be scalars.&lt;/strong&gt; Partition and sort keys can only be string, number,
or binary — no maps, no lists (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html" rel="noopener noreferrer"&gt;AWS docs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Simple key vs composite key
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;simple primary key&lt;/strong&gt; is just a partition key. It uniquely identifies an&lt;br&gt;
item, and you read it back with &lt;code&gt;GetItem&lt;/code&gt;. That's it — no range reads, no&lt;br&gt;
"give me the newest N".&lt;/p&gt;

&lt;p&gt;A composite key adds the sort key, and that single addition is what makes&lt;br&gt;
DynamoDB feel like a database instead of a hash map.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Simple key&lt;/th&gt;
&lt;th&gt;Composite key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Attributes&lt;/td&gt;
&lt;td&gt;Partition key only&lt;/td&gt;
&lt;td&gt;Partition key + sort key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uniqueness&lt;/td&gt;
&lt;td&gt;Partition key value&lt;/td&gt;
&lt;td&gt;The &lt;strong&gt;pair&lt;/strong&gt; of values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple items per partition&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Query&lt;/code&gt; a range&lt;/td&gt;
&lt;td&gt;No (&lt;code&gt;GetItem&lt;/code&gt; only)&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;begins_with&lt;/code&gt;, &lt;code&gt;between&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Natural fit&lt;/td&gt;
&lt;td&gt;Lookup by id&lt;/td&gt;
&lt;td&gt;Time series, one-to-many, history&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Model a sensor-readings table
&lt;/h2&gt;

&lt;p&gt;Say you collect temperature samples from a fleet of field sensors. The access&lt;br&gt;
pattern is "get the readings for one device, newest first, within a time&lt;br&gt;
window". That's a textbook composite key.&lt;/p&gt;

&lt;p&gt;Use the device id as the &lt;strong&gt;partition key&lt;/strong&gt; and the reading timestamp as the&lt;br&gt;
&lt;strong&gt;sort key&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;deviceId&lt;/th&gt;
&lt;th&gt;readingTs&lt;/th&gt;
&lt;th&gt;tempC&lt;/th&gt;
&lt;th&gt;humidity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DEV#a1b2&lt;/td&gt;
&lt;td&gt;2026-06-23T08:00:00Z&lt;/td&gt;
&lt;td&gt;21.4&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DEV#a1b2&lt;/td&gt;
&lt;td&gt;2026-06-23T08:05:00Z&lt;/td&gt;
&lt;td&gt;21.7&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DEV#a1b2&lt;/td&gt;
&lt;td&gt;2026-06-23T08:10:00Z&lt;/td&gt;
&lt;td&gt;22.1&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DEV#c9d8&lt;/td&gt;
&lt;td&gt;2026-06-23T08:00:00Z&lt;/td&gt;
&lt;td&gt;19.8&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three &lt;code&gt;DEV#a1b2&lt;/code&gt; readings land in the same partition, physically stored&lt;br&gt;
together and sorted by &lt;code&gt;readingTs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;AWS calls the partition key the &lt;em&gt;hash attribute&lt;/em&gt; and the sort key the &lt;em&gt;range&lt;br&gt;
attribute&lt;/em&gt; — the sort key is a range you can scan within&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html" rel="noopener noreferrer"&gt;AWS docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Here's how the items collapse into one &lt;a href="https://dynotable.com/docs/glossary#item-collection" rel="noopener noreferrer"&gt;item collection&lt;/a&gt; under each partition&lt;br&gt;
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%2Fzvwemhejhmbq1x1pcklq.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%2Fzvwemhejhmbq1x1pcklq.png" alt="DynamoDB Composite Primary Key" width="800" height="1564"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;Query&lt;/code&gt; against the partition key reads every reading for that device,&lt;br&gt;
already in timestamp order — no sorting on the client, no second round trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query the range, don't scan it
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;readingTs&lt;/code&gt; is an ISO-8601 string, it sorts lexicographically the same&lt;br&gt;
way it sorts chronologically. So a time-window read is a key-condition range,&lt;br&gt;
not a filter:&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;Query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deviceId  = "DEV#a1b2"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;readingTs BETWEEN "2026-06-23T08:00:00Z" AND "2026-06-23T08:10:00Z"&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;p&gt;That's a &lt;code&gt;KeyConditionExpression&lt;/code&gt; — it narrows the read &lt;em&gt;before&lt;/em&gt; DynamoDB&lt;br&gt;
returns data, so you pay only for the items in the window. A &lt;code&gt;FilterExpression&lt;/code&gt;&lt;br&gt;
runs &lt;em&gt;after&lt;/em&gt; the read and bills you for everything it scanned through; that's&lt;br&gt;
the &lt;a href="https://dynotable.com/learn/query-vs-scan" rel="noopener noreferrer"&gt;Scan footgun&lt;/a&gt; in miniature.&lt;/p&gt;

&lt;p&gt;The expression itself, with placeholders and typed values, is fiddly to write&lt;br&gt;
by hand. Build it visually with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; and copy the&lt;br&gt;
exact &lt;code&gt;KeyConditionExpression&lt;/code&gt; into your SDK call.&lt;/p&gt;

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

&lt;p&gt;The partition key in a &lt;code&gt;Query&lt;/code&gt; must be an exact equality (&lt;code&gt;=&lt;/code&gt;). Only the &lt;strong&gt;sort&lt;br&gt;
key&lt;/strong&gt; accepts ranges. You can't ask for "all readings across every device after&lt;br&gt;
8am" on the base table — that needs a different key design or a secondary index.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Design the sort key on purpose
&lt;/h2&gt;

&lt;p&gt;The sort key isn't free metadata — it's the only lever for range reads, so&lt;br&gt;
shape it to your queries.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a sortable timestamp.&lt;/strong&gt; ISO-8601 strings or &lt;a href="https://dynotable.com/docs/glossary#zero-padding" rel="noopener noreferrer"&gt;zero-padded&lt;/a&gt; epoch numbers
sort correctly; raw localized dates don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefix it for one-to-many &lt;a href="https://dynotable.com/docs/glossary#key-overloading" rel="noopener noreferrer"&gt;overloading&lt;/a&gt;.&lt;/strong&gt; A sort key like
&lt;code&gt;READING#2026-06-23T08:00:00Z&lt;/code&gt; lets you mix entity types under one partition
and slice them with &lt;code&gt;begins_with&lt;/code&gt;. That's the seam into
&lt;a href="https://dynotable.com/learn/single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the high-cardinality dimension in the partition key.&lt;/strong&gt; Sensor id has
thousands of values, so it spreads writes evenly. A low-cardinality partition
key (say, &lt;code&gt;region&lt;/code&gt;) creates a &lt;a href="https://dynotable.com/docs/glossary#hot-partition" rel="noopener noreferrer"&gt;hot partition&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When a composite key bites you
&lt;/h2&gt;

&lt;p&gt;It's a commitment, not a convenience. The trap: you pick a partition key, ship,&lt;br&gt;
then discover an access pattern that needs a &lt;em&gt;different&lt;/em&gt; grouping — "all&lt;br&gt;
readings above 30°C across the whole fleet".&lt;/p&gt;

&lt;p&gt;The base table can't answer that; the partition key is fixed. Your options are a&lt;br&gt;
&lt;a href="https://dynotable.com/learn/gsi-vs-lsi" rel="noopener noreferrer"&gt;global secondary index&lt;/a&gt; with a different key, or&lt;br&gt;
restructuring.&lt;/p&gt;

&lt;p&gt;Enumerate your reads &lt;em&gt;before&lt;/em&gt; you commit the key schema. Changing a primary key&lt;br&gt;
means a table migration, not an &lt;code&gt;ALTER TABLE&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Composite keys are the foundation under item collections, one-to-many&lt;br&gt;
relationships, and most useful index designs — read&lt;br&gt;
&lt;a href="https://dynotable.com/learn/single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://dynotable.com/learn/gsi-vs-lsi" rel="noopener noreferrer"&gt;GSI vs LSI&lt;/a&gt; next to see where they lead.&lt;/p&gt;

&lt;p&gt;Sketch your &lt;code&gt;KeyConditionExpression&lt;/code&gt; in the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt;, then&lt;br&gt;
&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;try DynoTable&lt;/a&gt; to browse your real partitions and watch the sort&lt;br&gt;
order line up against your own tables.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Batch Operations: BatchGetItem &amp; BatchWriteItem</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sun, 26 Jul 2026 06:47:38 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-batch-operations-batchgetitem-batchwriteitem-27g9</link>
      <guid>https://dev.to/dynotable/dynamodb-batch-operations-batchgetitem-batchwriteitem-27g9</guid>
      <description>&lt;p&gt;When you need to read or write many items at once, firing one &lt;code&gt;GetItem&lt;/code&gt; or &lt;code&gt;PutItem&lt;/code&gt;&lt;br&gt;
per item means one network round trip per item — slow, and chatty. DynamoDB's batch&lt;br&gt;
APIs fold many item operations into a &lt;strong&gt;single request&lt;/strong&gt;: &lt;code&gt;BatchGetItem&lt;/code&gt; for reads,&lt;br&gt;
&lt;code&gt;BatchWriteItem&lt;/code&gt; for writes.&lt;/p&gt;

&lt;p&gt;They're a throughput-and-latency win, not a consistency guarantee — and that&lt;br&gt;
distinction is where people get burned. A batch is &lt;strong&gt;not&lt;/strong&gt; a transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are DynamoDB batch operations?
&lt;/h2&gt;

&lt;p&gt;DynamoDB batch operations fold many item reads or writes into a single request: &lt;code&gt;BatchGetItem&lt;/code&gt; fetches up to 100 items, &lt;code&gt;BatchWriteItem&lt;/code&gt; puts or deletes up to 25, each capped at 16 MB. They save round trips, not capacity. Critically, a batch is &lt;strong&gt;not&lt;/strong&gt; a transaction — items succeed or fail independently, with no rollback.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BatchGetItem&lt;/code&gt;&lt;/strong&gt; — fetch up to &lt;strong&gt;100 items&lt;/strong&gt; (or 16 MB) across one or more tables
in one call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BatchWriteItem&lt;/code&gt;&lt;/strong&gt; — up to &lt;strong&gt;25 put/delete operations&lt;/strong&gt; (or 16 MB) in one call.
No updates — puts and deletes only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not atomic.&lt;/strong&gt; Individual items can succeed while others fail. There's no rollback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial failure is normal.&lt;/strong&gt; Throttled items come back in &lt;code&gt;UnprocessedItems&lt;/code&gt; /
&lt;code&gt;UnprocessedKeys&lt;/code&gt; — you must retry them yourself, with backoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same capacity cost as the individual calls&lt;/strong&gt; — batching saves round trips, not
capacity units.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: many items, one round trip
&lt;/h2&gt;

&lt;p&gt;Say you run a support desk. A dashboard needs to load 50 tickets by ID to render a&lt;br&gt;
queue; an overnight job archives 1,000 resolved tickets. Doing that one item at a time&lt;br&gt;
is 50 (or 1,000) sequential round trips — latency stacks up and the job crawls.&lt;/p&gt;

&lt;p&gt;Batching collapses those into a handful of calls. The 50-ticket read becomes a single&lt;br&gt;
&lt;code&gt;BatchGetItem&lt;/code&gt;; the archive job becomes a stream of &lt;code&gt;BatchWriteItem&lt;/code&gt; calls of 25&lt;br&gt;
deletes each. Far fewer round trips, the same data moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the batch APIs work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;BatchGetItem&lt;/code&gt;&lt;/strong&gt; takes a set of primary keys (across one or more tables) and returns&lt;br&gt;
the matching items. You can request strongly consistent reads per table. Anything it&lt;br&gt;
couldn't read — usually because the request brushed a throughput limit — comes back in&lt;br&gt;
&lt;strong&gt;&lt;code&gt;UnprocessedKeys&lt;/code&gt;&lt;/strong&gt; rather than failing the whole call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;BatchWriteItem&lt;/code&gt;&lt;/strong&gt; takes a list of &lt;code&gt;PutRequest&lt;/code&gt; / &lt;code&gt;DeleteRequest&lt;/code&gt; operations. Note&lt;br&gt;
what's missing: there is &lt;strong&gt;no update&lt;/strong&gt;. A batch write either replaces a whole item&lt;br&gt;
(put) or removes it (delete) — to modify specific attributes you still need&lt;br&gt;
&lt;code&gt;UpdateItem&lt;/code&gt;. Items it couldn't write come back in &lt;strong&gt;&lt;code&gt;UnprocessedItems&lt;/code&gt;&lt;/strong&gt;.&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%2F3f6yb0ivqerw1tqin528.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%2F3f6yb0ivqerw1tqin528.png" alt="DynamoDB Batch Operations: BatchGetItem &amp;amp; BatchWriteItem" width="800" height="847"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The key mental model: a batch is a &lt;strong&gt;bundle of independent operations&lt;/strong&gt;, each&lt;br&gt;
succeeding or failing on its own — not one all-or-nothing unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batches are not transactions
&lt;/h2&gt;

&lt;p&gt;This is the trap. If your archive job's batch hits a throughput limit halfway, some&lt;br&gt;
tickets are deleted and some aren't — and DynamoDB does &lt;strong&gt;not&lt;/strong&gt; undo the ones that&lt;br&gt;
went through. There's no rollback, no isolation, no "all 25 or none."&lt;/p&gt;

&lt;p&gt;If you need all-or-nothing semantics — "move the ticket to archived &lt;strong&gt;and&lt;/strong&gt; decrement&lt;br&gt;
the open-tickets counter, or do neither" — that's&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-transactions" rel="noopener noreferrer"&gt;&lt;code&gt;TransactWriteItems&lt;/code&gt;&lt;/a&gt;, not a batch. Transactions cost&lt;br&gt;
more (each operation is billed double) and cap at 100 items, but they give you the&lt;br&gt;
atomicity batches deliberately don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling unprocessed items
&lt;/h2&gt;

&lt;p&gt;A correct batch caller &lt;strong&gt;always&lt;/strong&gt; checks the unprocessed set and retries it. DynamoDB&lt;br&gt;
returns &lt;code&gt;UnprocessedItems&lt;/code&gt;/&lt;code&gt;UnprocessedKeys&lt;/code&gt; whenever the request as a whole was&lt;br&gt;
accepted but some items couldn't be served — typically transient throttling.&lt;/p&gt;

&lt;p&gt;Re-submit only the unprocessed items, with&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations" rel="noopener noreferrer"&gt;exponential backoff and jitter&lt;/a&gt;.&lt;br&gt;
Treating a batch as fire-and-forget silently drops writes — the kind of bug that&lt;br&gt;
surfaces months later as missing data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch writes in DynoTable
&lt;/h2&gt;

&lt;p&gt;Estimate what a bulk job will cost first with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator" rel="noopener noreferrer"&gt;DynamoDB pricing calculator&lt;/a&gt; — a batch consumes the&lt;br&gt;
same capacity as the individual writes it bundles, just in fewer requests.&lt;/p&gt;

&lt;p&gt;In DynoTable, you stage your edits locally and review them before committing them —&lt;br&gt;
bulk changes across many rows go out as grouped requests rather than one API call&lt;br&gt;
each. Bulk deletes go out as batched writes, with the unprocessed-item retry handled&lt;br&gt;
for you.&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%2F10o6b0smsim3fh91tue1.webp" 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%2F10o6b0smsim3fh91tue1.webp" alt="Reviewing staged edits before committing them as a batch in DynoTable." 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;Always retry &lt;code&gt;UnprocessedItems&lt;/code&gt;/&lt;code&gt;UnprocessedKeys&lt;/code&gt;&lt;/strong&gt; with backoff — they're
expected, not exceptional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No partial-failure rollback.&lt;/strong&gt; Need atomicity? Use
&lt;a href="https://dynotable.com/learn/dynamodb-transactions" rel="noopener noreferrer"&gt;transactions&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No updates in a batch write&lt;/strong&gt; — &lt;code&gt;BatchWriteItem&lt;/code&gt; is put/delete only; reach for
&lt;code&gt;UpdateItem&lt;/code&gt; to change attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the per-call caps&lt;/strong&gt; — 25 writes / 100 reads / 16 MB. Exceeding them fails
the whole call with a &lt;code&gt;ValidationException&lt;/code&gt;
(&lt;a href="https://dynotable.com/dynamodb-errors/batchgetitem-too-many-items" rel="noopener noreferrer"&gt;too many items in &lt;code&gt;BatchGetItem&lt;/code&gt;&lt;/a&gt;,
&lt;a href="https://dynotable.com/dynamodb-errors/batchwriteitem-too-many-items" rel="noopener noreferrer"&gt;in &lt;code&gt;BatchWriteItem&lt;/code&gt;&lt;/a&gt;). Page
through larger jobs; see &lt;a href="https://dynotable.com/learn/pagination" rel="noopener noreferrer"&gt;pagination&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Want to run bulk reads and writes without scripting the retry loop?&lt;br&gt;
&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;Download DynoTable&lt;/a&gt; and edit your tables directly.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB BatchGetItem in Python (boto3)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:44:48 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-batchgetitem-in-python-boto3-nib</link>
      <guid>https://dev.to/dynotable/dynamodb-batchgetitem-in-python-boto3-nib</guid>
      <description>&lt;p&gt;&lt;code&gt;batch_get_item&lt;/code&gt; fetches up to &lt;strong&gt;100 items by primary key&lt;/strong&gt; in a single request (16 MB max). With the boto3 &lt;strong&gt;low-level client&lt;/strong&gt; you pass a &lt;code&gt;RequestItems&lt;/code&gt; map of table → keys — and you &lt;strong&gt;must&lt;/strong&gt; loop on &lt;code&gt;UnprocessedKeys&lt;/code&gt;, because a batch can legally return only part of what you asked for.&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;time&lt;/span&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="n"&gt;request_items&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;Music&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;Keys&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="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="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;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="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;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="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;Misty&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;request_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&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;batch_get_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RequestItems&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request_items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&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;Responses&lt;/span&gt;&lt;span class="sh"&gt;"&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;Music&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="c1"&gt;# A partial result is NOT an error: throttling, a &amp;gt;16 MB response, or an
&lt;/span&gt;    &lt;span class="c1"&gt;# internal failure returns the leftovers in UnprocessedKeys. Retry them
&lt;/span&gt;    &lt;span class="c1"&gt;# with exponential backoff.
&lt;/span&gt;    &lt;span class="n"&gt;request_items&lt;/span&gt; &lt;span class="o"&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;UnprocessedKeys&lt;/span&gt;&lt;span class="sh"&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;request_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&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;Fetched &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;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; items&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;RequestItems&lt;/code&gt;&lt;/strong&gt; — a map of table name → &lt;code&gt;{"Keys": [...]}&lt;/code&gt;; one request can span multiple tables. Every key must be the &lt;strong&gt;full primary key&lt;/strong&gt; (partition + sort for a composite key), in DynamoDB JSON (&lt;code&gt;{"S": ...}&lt;/code&gt;, &lt;code&gt;{"N": ...}&lt;/code&gt;). More than 100 keys, or the same key twice, raises a &lt;code&gt;ValidationException&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Responses&lt;/code&gt;&lt;/strong&gt; — a map of table name → the items found. Items come back in &lt;strong&gt;no particular order&lt;/strong&gt;, and keys that don't exist simply don't appear — match results to requests by their key attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UnprocessedKeys&lt;/code&gt;&lt;/strong&gt; — the retry loop above is not optional. If the response passes 16 MB, throughput runs out, or a partition read passes 1 MB, DynamoDB returns the remainder here &lt;strong&gt;in &lt;code&gt;RequestItems&lt;/code&gt; form&lt;/strong&gt;, ready to feed straight back in (an empty map means done — falsy in Python, which is what ends the &lt;code&gt;while&lt;/code&gt;). AWS strongly recommends &lt;strong&gt;exponential backoff&lt;/strong&gt; between retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency&lt;/strong&gt; — batch reads are &lt;strong&gt;eventually consistent&lt;/strong&gt; by default; set &lt;code&gt;"ConsistentRead": True&lt;/code&gt; per table for strongly consistent reads.&lt;/li&gt;
&lt;li&gt;Per table you can also pass &lt;strong&gt;&lt;code&gt;ProjectionExpression&lt;/code&gt;&lt;/strong&gt; (with &lt;code&gt;ExpressionAttributeNames&lt;/code&gt;) to fetch only some attributes.&lt;/li&gt;
&lt;li&gt;Prefer native Python types instead of DynamoDB JSON? The &lt;strong&gt;resource&lt;/strong&gt; API offers &lt;code&gt;dynamodb.batch_get_item(...)&lt;/code&gt; with plain values, but the retry contract is the same — &lt;code&gt;UnprocessedKeys&lt;/code&gt; is yours to drain either way.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Prefer not to hand-assemble DynamoDB-JSON key maps? The &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; builds typed key/value maps and copies runnable code, and the &lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator" rel="noopener noreferrer"&gt;item size calculator&lt;/a&gt; shows how close a batch gets to the 16 MB ceiling.&lt;/p&gt;

&lt;p&gt;To fetch and inspect items across tables in a GUI — no retry loops to write — &lt;a href="https://dynotable.com/download" 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/code-examples/batchgetitem-nodejs" rel="noopener noreferrer"&gt;DynamoDB BatchGetItem in Node.js&lt;/a&gt; — the same batch read with AWS SDK v3.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/code-examples/batchgetitem-cli" rel="noopener noreferrer"&gt;DynamoDB BatchGetItem with the AWS CLI&lt;/a&gt; — the same batch read from the shell.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/code-examples/getitem-python" rel="noopener noreferrer"&gt;DynamoDB GetItem in Python&lt;/a&gt; — the single-item read this batches.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-batch-operations" 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/batchgetitem-too-many-items" rel="noopener noreferrer"&gt;"Too many items requested for the BatchGetItem call"&lt;/a&gt; — more than 100 keys in one request.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/duplicate-keys-in-batchget" rel="noopener noreferrer"&gt;"Provided list of item keys contains duplicates"&lt;/a&gt; — the same key twice 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/amazondynamodb/latest/APIReference/API_BatchGetItem.html" rel="noopener noreferrer"&gt;BatchGetItem — 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/batch_get_item.html" rel="noopener noreferrer"&gt;DynamoDB.Client.batch_get_item — Boto3 documentation&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-13 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 BatchGetItem in Node.js (AWS SDK v3)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:44:17 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-batchgetitem-in-nodejs-aws-sdk-v3-195l</link>
      <guid>https://dev.to/dynotable/dynamodb-batchgetitem-in-nodejs-aws-sdk-v3-195l</guid>
      <description>&lt;p&gt;&lt;code&gt;BatchGetItem&lt;/code&gt; fetches up to &lt;strong&gt;100 items by primary key&lt;/strong&gt; in a single request (16 MB max). In AWS SDK v3 you send a &lt;code&gt;BatchGetItemCommand&lt;/code&gt; with a &lt;code&gt;RequestItems&lt;/code&gt; map of table → keys — and you &lt;strong&gt;must&lt;/strong&gt; loop on &lt;code&gt;UnprocessedKeys&lt;/code&gt;, because a batch can legally return only part of what you asked for.&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;BatchGetItemCommand&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;requestItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;Music&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Keys&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;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;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;A Mis Abuelos&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;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;Ella Fitzgerald&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;Misty&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="p"&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;do&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;response&lt;/span&gt; &lt;span class="o"&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BatchGetItemCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;RequestItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestItems&lt;/span&gt;&lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Responses&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;Music&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]));&lt;/span&gt;

  &lt;span class="c1"&gt;// A partial result is NOT an error: throttling, a &amp;gt;16 MB response, or an&lt;/span&gt;
  &lt;span class="c1"&gt;// internal failure returns the leftovers in UnprocessedKeys. Retry them&lt;/span&gt;
  &lt;span class="c1"&gt;// with exponential backoff.&lt;/span&gt;
  &lt;span class="nx"&gt;requestItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UnprocessedKeys&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;requestItems&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Object&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="nx"&gt;requestItems&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&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;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestItems&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Object&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="nx"&gt;requestItems&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="s2"&gt;`Fetched &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; items`&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;RequestItems&lt;/code&gt;&lt;/strong&gt; — a map of table name → &lt;code&gt;{ Keys: [...] }&lt;/code&gt;; one request can span multiple tables. Every key must be the &lt;strong&gt;full primary key&lt;/strong&gt; (partition + sort for a composite key). More than 100 keys, or the same key twice, fails with a &lt;code&gt;ValidationException&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Responses&lt;/code&gt;&lt;/strong&gt; — a map of table name → the items found. Items come back in &lt;strong&gt;no particular order&lt;/strong&gt;, and keys that don't exist simply don't appear — match results to requests by their key attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UnprocessedKeys&lt;/code&gt;&lt;/strong&gt; — the retry loop above is not optional. If the response passes 16 MB, throughput runs out, or a partition read passes 1 MB, DynamoDB returns the remainder here &lt;strong&gt;in &lt;code&gt;RequestItems&lt;/code&gt; form&lt;/strong&gt;, ready to feed straight back in. AWS strongly recommends &lt;strong&gt;exponential backoff&lt;/strong&gt; between retries — an immediate retry tends to re-hit the same throttle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency&lt;/strong&gt; — batch reads are &lt;strong&gt;eventually consistent&lt;/strong&gt; by default; set &lt;code&gt;ConsistentRead: true&lt;/code&gt; per table for strongly consistent reads.&lt;/li&gt;
&lt;li&gt;Per table you can also pass &lt;strong&gt;&lt;code&gt;ProjectionExpression&lt;/code&gt;&lt;/strong&gt; (with &lt;code&gt;ExpressionAttributeNames&lt;/code&gt;) to fetch only some attributes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BatchGetItem&lt;/code&gt; may retrieve items in parallel server-side — it saves round trips, not read capacity: DynamoDB bills each item in the batch as an individual &lt;code&gt;GetItem&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Prefer not to hand-assemble DynamoDB-JSON key maps? The &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; builds typed key/value maps and copies runnable code, and the &lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator" rel="noopener noreferrer"&gt;item size calculator&lt;/a&gt; shows how close a batch gets to the 16 MB ceiling.&lt;/p&gt;

&lt;p&gt;To fetch and inspect items across tables in a GUI — no retry loops to write — &lt;a href="https://dynotable.com/download" 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/code-examples/batchgetitem-python" rel="noopener noreferrer"&gt;DynamoDB BatchGetItem in Python&lt;/a&gt; — the same batch read with boto3.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/code-examples/batchgetitem-cli" rel="noopener noreferrer"&gt;DynamoDB BatchGetItem with the AWS CLI&lt;/a&gt; — the same batch read from the shell.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/code-examples/getitem-nodejs" rel="noopener noreferrer"&gt;DynamoDB GetItem in Node.js&lt;/a&gt; — the single-item read this batches.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-batch-operations" 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/batchgetitem-too-many-items" rel="noopener noreferrer"&gt;"Too many items requested for the BatchGetItem call"&lt;/a&gt; — more than 100 keys in one request.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/duplicate-keys-in-batchget" rel="noopener noreferrer"&gt;"Provided list of item keys contains duplicates"&lt;/a&gt; — the same key twice 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/amazondynamodb/latest/APIReference/API_BatchGetItem.html" rel="noopener noreferrer"&gt;BatchGetItem — 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;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-13 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 BatchGetItem with the AWS CLI</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:44:16 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-batchgetitem-with-the-aws-cli-1nd2</link>
      <guid>https://dev.to/dynotable/dynamodb-batchgetitem-with-the-aws-cli-1nd2</guid>
      <description>&lt;p&gt;&lt;code&gt;aws dynamodb batch-get-item&lt;/code&gt; fetches up to &lt;strong&gt;100 items by primary key&lt;/strong&gt; in a single command (16 MB max). The keys go in &lt;code&gt;--request-items&lt;/code&gt; as a map of table → keys in &lt;strong&gt;DynamoDB JSON&lt;/strong&gt; — and you must check &lt;code&gt;UnprocessedKeys&lt;/code&gt; in the output, because a batch can legally return only part of what you asked for.&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 batch-get-item &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--request-items&lt;/span&gt; &lt;span class="s1"&gt;'{
    "Music": {
      "Keys": [
        {"Artist": {"S": "Arturo Sandoval"}, "SongTitle": {"S": "Cubano Chant"}},
        {"Artist": {"S": "Arturo Sandoval"}, "SongTitle": {"S": "A Mis Abuelos"}},
        {"Artist": {"S": "Ella Fitzgerald"}, "SongTitle": {"S": "Misty"}}
      ]
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output maps each table to the items found, plus any leftovers:&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;"Responses"&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;"Music"&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="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;"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="err"&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;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"UnprocessedKeys"&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;h2&gt;
  
  
  Explanation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--request-items&lt;/code&gt;&lt;/strong&gt; — a map of table name → &lt;code&gt;{"Keys": [...]}&lt;/code&gt;; one command can span multiple tables. Every key must be the &lt;strong&gt;full primary key&lt;/strong&gt; (partition + sort for a composite key). More than 100 keys, or the same key twice, fails with a &lt;code&gt;ValidationException&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Responses&lt;/code&gt;&lt;/strong&gt; — items come back in &lt;strong&gt;no particular order&lt;/strong&gt;, and keys that don't exist simply don't appear — match results to requests by their key attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UnprocessedKeys&lt;/code&gt;&lt;/strong&gt; — unlike &lt;code&gt;query&lt;/code&gt;/&lt;code&gt;scan&lt;/code&gt;, the CLI does &lt;strong&gt;not&lt;/strong&gt; auto-retry a partial batch (it's not a pagination token). If the map is non-empty — throttling, a &amp;gt;16 MB response — re-run the command with that exact map as &lt;code&gt;--request-items&lt;/code&gt; (it's already in the right form), backing off between attempts.&lt;/li&gt;
&lt;li&gt;Per table you can add &lt;code&gt;"ProjectionExpression": "..."&lt;/code&gt; (with &lt;code&gt;"ExpressionAttributeNames"&lt;/code&gt;) to fetch only some attributes, and &lt;code&gt;"ConsistentRead": true&lt;/code&gt; for strongly consistent reads — batch reads are &lt;strong&gt;eventually consistent&lt;/strong&gt; by default.&lt;/li&gt;
&lt;li&gt;Tip: for more than a few keys, keep the map in a file and pass &lt;code&gt;--request-items file://keys.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Hand-writing nested DynamoDB JSON in shell quotes is error-prone. The &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; builds typed key/value maps and copies ready-to-run CLI commands.&lt;/p&gt;

&lt;p&gt;To fetch and inspect items across tables in a GUI — no JSON escaping, no re-run bookkeeping — &lt;a href="https://dynotable.com/download" 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/code-examples/batchgetitem-nodejs" rel="noopener noreferrer"&gt;DynamoDB BatchGetItem in Node.js&lt;/a&gt; — the same batch read with AWS SDK v3.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/code-examples/batchgetitem-python" rel="noopener noreferrer"&gt;DynamoDB BatchGetItem in Python&lt;/a&gt; — the same batch read with boto3.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/code-examples/getitem-cli" rel="noopener noreferrer"&gt;DynamoDB GetItem with the AWS CLI&lt;/a&gt; — the single-item read this batches.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-batch-operations" 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/batchgetitem-too-many-items" rel="noopener noreferrer"&gt;"Too many items requested for the BatchGetItem call"&lt;/a&gt; — more than 100 keys in one request.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/duplicate-keys-in-batchget" rel="noopener noreferrer"&gt;"Provided list of item keys contains duplicates"&lt;/a&gt; — the same key twice 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/amazondynamodb/latest/APIReference/API_BatchGetItem.html" rel="noopener noreferrer"&gt;BatchGetItem — 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/batch-get-item.html" rel="noopener noreferrer"&gt;batch-get-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/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-13 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 Backup &amp; Point-in-Time Recovery</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:43:30 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-backup-point-in-time-recovery-eom</link>
      <guid>https://dev.to/dynotable/dynamodb-backup-point-in-time-recovery-eom</guid>
      <description>&lt;p&gt;DynamoDB protects your data two ways. &lt;strong&gt;On-demand backups&lt;/strong&gt; are full snapshots you&lt;br&gt;
take and keep indefinitely. &lt;strong&gt;Point-in-time recovery (PITR)&lt;/strong&gt; is continuous,&lt;br&gt;
automatic backup that lets you restore the table to any second within a rolling&lt;br&gt;
window. Both restore to a &lt;em&gt;new&lt;/em&gt; table — they're recovery tools, not an undo button.&lt;/p&gt;

&lt;p&gt;For the audit log this is non-negotiable. It's an immutable compliance record; a&lt;br&gt;
bad migration that rewrites events, or an accidental bulk delete, has to be&lt;br&gt;
recoverable to the moment before the mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does DynamoDB backup and point-in-time recovery work?
&lt;/h2&gt;

&lt;p&gt;DynamoDB offers two backup types. Point-in-time recovery (PITR) takes continuous automatic backups, letting you restore to any second within a configurable 1-to-35-day window. On-demand backups are manual full snapshots kept indefinitely. Both restore to a new table, never over the original, so they are recovery tools rather than an in-place undo.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PITR = continuous backup, restore to any second&lt;/strong&gt; within a configurable window
of &lt;strong&gt;1 to 35 days&lt;/strong&gt; (it used to be a fixed 35).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-demand backups = manual full snapshots&lt;/strong&gt; kept as long as you want,
independent of PITR's window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restores create a new table.&lt;/strong&gt; You restore to a new name, then cut over — the
original is untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PITR is priced on table size&lt;/strong&gt;, not on the number of restore points — estimate
it with the &lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator" rel="noopener noreferrer"&gt;DynamoDB Pricing Calculator&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: a mistake you can't undo in place
&lt;/h2&gt;

&lt;p&gt;DynamoDB has no transaction log you can roll back and no "undo" on a write. If a&lt;br&gt;
migration script rewrites every event's &lt;code&gt;action&lt;/code&gt; field, or someone runs a delete&lt;br&gt;
that's wider than intended, the table is simply in the wrong state. Without&lt;br&gt;
backups, the data is gone.&lt;/p&gt;

&lt;p&gt;For an audit log — whose entire value is being a trustworthy record — "we can't get&lt;br&gt;
last Tuesday's events back" is a compliance failure, not just an inconvenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  How backup and PITR work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Point-in-time recovery&lt;/strong&gt;, once enabled, takes continuous automatic backups. Per&lt;br&gt;
the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Point-in-time-recovery.html" rel="noopener noreferrer"&gt;AWS docs&lt;/a&gt;,&lt;br&gt;
PITR gives you fully managed continuous backups of table data with per-second&lt;br&gt;
restore granularity. The window is&lt;br&gt;
configurable from &lt;strong&gt;1 to 35 days&lt;/strong&gt; via &lt;code&gt;RecoveryPeriodInDays&lt;/code&gt;, and you can restore&lt;br&gt;
to any second within it — up to roughly five minutes behind real time&lt;br&gt;
(&lt;code&gt;LatestRestorableDateTime&lt;/code&gt;) — including to a &lt;strong&gt;different region&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One important edge: &lt;strong&gt;decreasing the recovery period immediately reduces the&lt;br&gt;
earliest restore point&lt;/strong&gt;, and &lt;strong&gt;disabling then re-enabling PITR resets the&lt;br&gt;
recoverable start time&lt;/strong&gt; — you lose the prior continuous history.&lt;/p&gt;

&lt;p&gt;PITR also gates the managed &lt;a href="https://dynotable.com/learn/export-dynamodb-to-csv" rel="noopener noreferrer"&gt;export to S3&lt;/a&gt;: the&lt;br&gt;
export reads from the same continuous backups, so with PITR off,&lt;br&gt;
&lt;code&gt;ExportTableToPointInTime&lt;/code&gt; fails with&lt;br&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/export-no-pitr" rel="noopener noreferrer"&gt;&lt;code&gt;PointInTimeRecoveryUnavailableException&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-demand backups&lt;/strong&gt; are separate: manual, full-table snapshots you create&lt;br&gt;
explicitly and retain indefinitely, useful for a pre-migration checkpoint or a&lt;br&gt;
long-term compliance archive beyond the 35-day PITR window.&lt;/p&gt;

&lt;p&gt;Both &lt;strong&gt;restore to a new table&lt;/strong&gt;, not over the existing one:&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%2Fom9zoveeotiazu7bcrz2.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%2Fom9zoveeotiazu7bcrz2.png" alt="DynamoDB Backup &amp;amp; Point-in-Time Recovery" width="800" height="1095"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example: recovering from a bad migration
&lt;/h2&gt;

&lt;p&gt;A migration meant to add an &lt;code&gt;expiresAt&lt;/code&gt; attribute instead overwrote &lt;code&gt;action&lt;/code&gt; on&lt;br&gt;
every event with an empty string. PITR is on with a 35-day window, so you restore&lt;br&gt;
to the second &lt;em&gt;before&lt;/em&gt; the migration ran:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;restore PITR to 09:59:00&lt;/td&gt;
&lt;td&gt;new table audit-log-restored with correct actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;diff against live&lt;/td&gt;
&lt;td&gt;confirm only the migration's rows differ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cut app over to restored&lt;/td&gt;
&lt;td&gt;original left intact for forensics&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The corrupted table stays untouched while you verify the restore — you compare the&lt;br&gt;
restored events against the live ones, confirm the &lt;code&gt;action&lt;/code&gt; values are back, then&lt;br&gt;
repoint the app. Nothing is destroyed in the recovery itself.&lt;/p&gt;

&lt;p&gt;If the loss were a handful of items rather than a whole-table corruption, you could&lt;br&gt;
instead inspect the live data and the restored copy and copy just the affected rows&lt;br&gt;
across — see &lt;a href="https://dynotable.com/learn/copy-dynamodb-table" rel="noopener noreferrer"&gt;copy a DynamoDB table&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do it in DynoTable
&lt;/h2&gt;

&lt;p&gt;A restore is only as good as your verification of it. After restoring to&lt;br&gt;
&lt;code&gt;audit-log-restored&lt;/code&gt;, you need to actually look at the recovered events and confirm&lt;br&gt;
they match what they should have been before the mistake.&lt;/p&gt;

&lt;p&gt;DynoTable connects to the restored table like any other, so you can query the&lt;br&gt;
affected tenant's events, confirm the &lt;code&gt;action&lt;/code&gt; values are correct, and compare&lt;br&gt;
against the live table before you cut over — turning a restore from a leap of faith&lt;br&gt;
into a verified recovery.&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%2F9cifuy6myi6imtvf0sk6.webp" 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%2F9cifuy6myi6imtvf0sk6.webp" alt="Inspecting a PITR-restored audit-log table in DynoTable to verify the recovered events before cutting the app over." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can also export the recovered events for an offline compliance record — see&lt;br&gt;
&lt;a href="https://dynotable.com/learn/export-dynamodb-to-csv" rel="noopener noreferrer"&gt;export DynamoDB to CSV&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;Enable PITR &lt;em&gt;before&lt;/em&gt; you need it.&lt;/strong&gt; It only protects from the moment it's on —
there's no retroactive recovery. Turn it on for any table whose data you can't
afford to lose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disabling PITR resets the window.&lt;/strong&gt; Toggling it off and back on wipes the
continuous history; the recoverable start time begins again from re-enablement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restores aren't instant or free.&lt;/strong&gt; A restore provisions a whole new table and
takes time proportional to size; budget for the duration and the extra table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;35 days isn't archival.&lt;/strong&gt; For retention beyond the PITR window, take on-demand
backups or export to S3 — PITR is a recovery window, not long-term storage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That closes the audit-log operations loop: transactions for consistency, Streams&lt;br&gt;
for reaction, TTL for expiry, the right capacity mode for cost, global tables for&lt;br&gt;
region resilience, and PITR for data recovery. Revisit the&lt;br&gt;
&lt;a href="https://dynotable.com/learn/operations-cost" rel="noopener noreferrer"&gt;Operations &amp;amp; Cost overview&lt;/a&gt; to see how they fit together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;Download DynoTable&lt;/a&gt; to connect to a restored table and verify your&lt;br&gt;
recovery before you trust it.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Atomic Counters</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:43:29 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-atomic-counters-3h44</link>
      <guid>https://dev.to/dynotable/dynamodb-atomic-counters-3h44</guid>
      <description>&lt;p&gt;An atomic counter is a numeric attribute you bump in place with a single&lt;br&gt;
&lt;code&gt;UpdateItem&lt;/code&gt; call — no read first, no read-modify-write race. DynamoDB applies&lt;br&gt;
each increment in arrival order and never lets two writers clobber each other's&lt;br&gt;
count.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a DynamoDB atomic counter?
&lt;/h2&gt;

&lt;p&gt;A DynamoDB atomic counter is a numeric attribute you increment in place with a single &lt;code&gt;UpdateItem&lt;/code&gt; call using an &lt;code&gt;ADD&lt;/code&gt; (or &lt;code&gt;SET x = x + :n&lt;/code&gt;) update expression. DynamoDB reads, adds, and writes the value server-side, so concurrent writers serialize without lost updates — but it is not idempotent, so a retried call increments twice.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;ADD&lt;/code&gt; (or &lt;code&gt;SET x = x + :n&lt;/code&gt;) to increment in one call.&lt;/strong&gt; DynamoDB reads,
adds, and writes server-side — concurrent callers serialize, no lost updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No read first.&lt;/strong&gt; Coming from SQL you'd &lt;code&gt;SELECT&lt;/code&gt; then &lt;code&gt;UPDATE&lt;/code&gt;; here you skip
the read entirely and the operation is still safe under concurrency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic counters are &lt;em&gt;not&lt;/em&gt; idempotent.&lt;/strong&gt; A retried &lt;code&gt;UpdateItem&lt;/code&gt; increments
again. If you can't tolerate over- or undercounting, use a &lt;a href="https://dynotable.com/docs/glossary#condition-expression" rel="noopener noreferrer"&gt;conditional update&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ADD&lt;/code&gt; on a missing attribute starts at 0&lt;/strong&gt;, so the very first increment just
works — no seed write needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem with read-modify-write
&lt;/h2&gt;

&lt;p&gt;Say you track views on a video. The naive instinct, straight from SQL, is:&lt;br&gt;
&lt;code&gt;GetItem&lt;/code&gt;, add one in your app, &lt;code&gt;PutItem&lt;/code&gt; the new total back.&lt;/p&gt;

&lt;p&gt;Two viewers hit play at once. Both read &lt;code&gt;views = 41&lt;/code&gt;. Both write &lt;code&gt;42&lt;/code&gt;. You&lt;br&gt;
counted one view, not two. That's a lost update — the classic concurrency&lt;br&gt;
footgun, and it doesn't show up until you have traffic.&lt;/p&gt;

&lt;p&gt;In SQL you'd dodge it with &lt;code&gt;UPDATE videos SET views = views + 1&lt;/code&gt;, pushing the&lt;br&gt;
arithmetic into the database. DynamoDB has the same move, and it's the whole&lt;br&gt;
point of an atomic counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Increment in one call
&lt;/h2&gt;

&lt;p&gt;Model a per-video stats item. Partition key &lt;code&gt;VID#&amp;lt;id&amp;gt;&lt;/code&gt;, sort key &lt;code&gt;STATS#TOTAL&lt;/code&gt;,&lt;br&gt;
with a numeric &lt;code&gt;play_count&lt;/code&gt;:&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;play_count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"VID#9f3a"&lt;/td&gt;
&lt;td&gt;"STATS#TOTAL"&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To register a play, send one &lt;code&gt;UpdateItem&lt;/code&gt; with an &lt;code&gt;ADD&lt;/code&gt; clause:&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;# UpdateItem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Key               PK = "VID#9f3a", SK = "STATS#TOTAL"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UpdateExpression  ADD play_count :one&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Values            :one = 1&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;p&gt;DynamoDB reads &lt;code&gt;play_count&lt;/code&gt;, adds &lt;code&gt;1&lt;/code&gt;, and writes the result inside a single&lt;br&gt;
server-side operation. There's no window for another writer to slip in. Ten&lt;br&gt;
concurrent plays produce &lt;code&gt;+10&lt;/code&gt;, every time — that's what "atomic" buys you.&lt;/p&gt;

&lt;p&gt;You can build and copy this exact expression — names, values, and all four&lt;br&gt;
clause types — with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ADD&lt;/code&gt; works even when &lt;code&gt;play_count&lt;/code&gt; doesn't exist yet: DynamoDB treats a missing&lt;br&gt;
numeric attribute as &lt;code&gt;0&lt;/code&gt;, so the first play creates it at &lt;code&gt;1&lt;/code&gt;. No separate seed&lt;br&gt;
write. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.UpdateExpressions.html" rel="noopener noreferrer"&gt;AWS: Using update expressions&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  ADD vs SET +: pick one
&lt;/h2&gt;

&lt;p&gt;Two expressions do the same arithmetic. AWS recommends &lt;code&gt;SET&lt;/code&gt; for general use&lt;br&gt;
because it composes with other &lt;code&gt;SET&lt;/code&gt; actions and reads more explicitly. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.UpdateExpressions.html" rel="noopener noreferrer"&gt;AWS:&lt;br&gt;
Using update expressions&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;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ADD play_count :one&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;SET play_count = play_count + :one&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing attr&lt;/td&gt;
&lt;td&gt;Creates it, starting at 0&lt;/td&gt;
&lt;td&gt;Errors — needs &lt;code&gt;if_not_exists&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data types&lt;/td&gt;
&lt;td&gt;Numbers and sets only&lt;/td&gt;
&lt;td&gt;Numbers (and more) via &lt;code&gt;SET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Combine w/ &lt;code&gt;SET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Separate clause&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;SET&lt;/code&gt; clause, comma-separated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS guidance&lt;/td&gt;
&lt;td&gt;Fine for counters&lt;/td&gt;
&lt;td&gt;Recommended default&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the attribute might not exist and you want &lt;code&gt;SET&lt;/code&gt;, guard it:&lt;br&gt;
&lt;code&gt;SET play_count = if_not_exists(play_count, :zero) + :one&lt;/code&gt;. With &lt;code&gt;ADD&lt;/code&gt; you skip&lt;br&gt;
that — it seeds from 0 for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do it in DynoTable
&lt;/h2&gt;

&lt;p&gt;Open the stats item to inspect the live counter, then roll up a sharded counter&lt;br&gt;
with &lt;code&gt;SUM&lt;/code&gt; and &lt;code&gt;GROUP BY&lt;/code&gt; in the SQL Workbench to see the total across every&lt;br&gt;
&lt;code&gt;STATS#TOTAL#0..N&lt;/code&gt; row. To draft the increment itself, use the web&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; to compose the&lt;br&gt;
&lt;code&gt;ADD&lt;/code&gt; &lt;code&gt;UpdateItem&lt;/code&gt; expression, names and values included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: counters aren't idempotent
&lt;/h2&gt;

&lt;p&gt;Here's the part that bites teams in production. An atomic counter increments&lt;br&gt;
&lt;strong&gt;every time&lt;/strong&gt; &lt;code&gt;UpdateItem&lt;/code&gt; runs. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithItems.html" rel="noopener noreferrer"&gt;AWS: Working with items&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Picture a network blip: you send the increment, the connection drops before the&lt;br&gt;
response comes back, and you don't know whether it landed. You retry. If the&lt;br&gt;
first call &lt;em&gt;did&lt;/em&gt; succeed, you've now counted that play twice.&lt;/p&gt;

&lt;p&gt;For video views that's fine — a few double-counts in a million plays won't hurt&lt;br&gt;
anyone, and AWS calls this exact "track visitors" case the canonical use of&lt;br&gt;
atomic counters. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithItems.html" rel="noopener noreferrer"&gt;AWS: Working with items&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;not&lt;/strong&gt; fine for anything that must be exact: inventory you can oversell,&lt;br&gt;
credits you can double-spend, a balance you can corrupt. There, reach for a&lt;br&gt;
conditional update.&lt;/p&gt;

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

&lt;p&gt;Don't retry an atomic-counter &lt;code&gt;UpdateItem&lt;/code&gt; blindly when exactness matters. A&lt;br&gt;
"failed" request may have committed. Each retry risks another increment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When you need exactness: conditional updates
&lt;/h2&gt;

&lt;p&gt;A conditional update is idempotent &lt;em&gt;if you condition on the same attribute you're&lt;br&gt;
changing&lt;/em&gt;. Increment &lt;code&gt;play_count&lt;/code&gt; to &lt;code&gt;42&lt;/code&gt;, but only if it's currently &lt;code&gt;41&lt;/code&gt;:&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;# UpdateItem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Key                  PK = "VID#9f3a", SK = "STATS#TOTAL"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UpdateExpression     SET play_count = :next&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ConditionExpression  play_count = :current&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Values               :next = 42, :current = 41&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;p&gt;Now a retry is safe: if the first write already moved &lt;code&gt;play_count&lt;/code&gt; to &lt;code&gt;42&lt;/code&gt;, the&lt;br&gt;
condition &lt;code&gt;play_count = 41&lt;/code&gt; fails the second time and nothing changes. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/WorkingWithItems.html" rel="noopener noreferrer"&gt;AWS:&lt;br&gt;
Working with items&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;The cost is concurrency. Two writers racing on the same condition means one wins&lt;br&gt;
and one gets a &lt;code&gt;ConditionalCheckFailedException&lt;/code&gt; to retry — you've traded the&lt;br&gt;
unconditional counter's throughput for correctness. For exact, contended&lt;br&gt;
counters that's the right trade. For view counts it's overkill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One &lt;a href="https://dynotable.com/docs/glossary#hot-partition" rel="noopener noreferrer"&gt;hot item&lt;/a&gt;.&lt;/strong&gt; A single counter row is one partition key. A viral video
hammering &lt;code&gt;VID#9f3a&lt;/code&gt; / &lt;code&gt;STATS#TOTAL&lt;/code&gt; can hit a per-partition write ceiling.
Shard it: spread writes across &lt;code&gt;STATS#TOTAL#0..N&lt;/code&gt; and sum on read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No batch increment.&lt;/strong&gt; &lt;code&gt;BatchWriteItem&lt;/code&gt; is put/delete only — it can't run
&lt;a href="https://dynotable.com/docs/glossary#update-expression" rel="noopener noreferrer"&gt;update expressions&lt;/a&gt;. Counters go through &lt;code&gt;UpdateItem&lt;/code&gt;,
one item per call. If you must bump several counters atomically,
&lt;code&gt;TransactWriteItems&lt;/code&gt; runs Update actions on up to 100 items in one request, at
roughly double the write cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ADD&lt;/code&gt; is numbers and sets only.&lt;/strong&gt; It won't touch strings or booleans;
that's a &lt;code&gt;SET&lt;/code&gt;. See &lt;a href="https://dynotable.com/learn/data-types" rel="noopener noreferrer"&gt;DynamoDB data types&lt;/a&gt; for the full
attribute model.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Atomic counters are a write pattern; how you &lt;em&gt;read&lt;/em&gt; aggregates back is a modeling&lt;br&gt;
question — see &lt;a href="https://dynotable.com/learn/single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt; for keeping&lt;br&gt;
stats items beside their parent, and &lt;a href="https://dynotable.com/learn/query-vs-scan" rel="noopener noreferrer"&gt;Query vs Scan&lt;/a&gt; so&lt;br&gt;
rolling up a sharded counter stays a &lt;code&gt;Query&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Draft and copy the increment in the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt;, then&lt;br&gt;
&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;try DynoTable&lt;/a&gt; to run atomic updates against your own tables and&lt;br&gt;
watch the counts move.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>How we made cheap LLMs reliable at DynamoDB: tool validators + evals</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Fri, 17 Jul 2026 20:30:32 +0000</pubDate>
      <link>https://dev.to/dynotable/how-we-made-cheap-llms-reliable-at-dynamodb-tool-validators-evals-4o3o</link>
      <guid>https://dev.to/dynotable/how-we-made-cheap-llms-reliable-at-dynamodb-tool-validators-evals-4o3o</guid>
      <description>&lt;p&gt;DynoTable's &lt;a href="https://dynotable.com/docs/ai-chat" rel="noopener noreferrer"&gt;AI agent&lt;/a&gt; runs on your own&lt;br&gt;
&lt;a href="https://dynotable.com/docs/glossary#bedrock" rel="noopener noreferrer"&gt;AWS Bedrock&lt;/a&gt; credentials, which means you pick the model — and&lt;br&gt;
many people pick a cheap one. So we don't tune the agent against a frontier model. Our floor is&lt;br&gt;
&lt;strong&gt;Amazon Nova Lite&lt;/strong&gt;, a model that costs a rounding error per query and&lt;br&gt;
fails in ways frontier models don't.&lt;/p&gt;

&lt;p&gt;Here's the punchline of this whole post: on one &lt;a href="https://dynotable.com/docs/glossary#eval" rel="noopener noreferrer"&gt;Eval&lt;/a&gt; scenario — a join&lt;br&gt;
that needs the composite-key table as the base — Nova Lite scored &lt;strong&gt;0%&lt;/strong&gt;. We&lt;br&gt;
didn't switch models, didn't add few-shot examples, didn't fine-tune. We&lt;br&gt;
rewrote one validator error message to say exactly what to do instead. It&lt;br&gt;
went to &lt;strong&gt;100%&lt;/strong&gt;, flipping the join on the very next step.&lt;/p&gt;

&lt;p&gt;The model was never the bottleneck. The message was.&lt;/p&gt;

&lt;p&gt;This is the engineering story behind that lesson: the validators that sit&lt;br&gt;
around every tool call, the things we deliberately do &lt;em&gt;not&lt;/em&gt; validate, and&lt;br&gt;
the eval harness that arbitrates every change. If you're building an&lt;br&gt;
&lt;a href="https://dynotable.com/docs/glossary#ai-agent" rel="noopener noreferrer"&gt;agent&lt;/a&gt; on a budget model — over DynamoDB or anything else —&lt;br&gt;
most of it transfers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why cheap models fail at DynamoDB specifically
&lt;/h2&gt;

&lt;p&gt;DynamoDB is a hostile target for a small model because the query surface&lt;br&gt;
&lt;em&gt;looks&lt;/em&gt; like SQL and isn't. &lt;a href="https://dynotable.com/learn/partiql-vs-sql" rel="noopener noreferrer"&gt;PartiQL&lt;/a&gt; accepts&lt;br&gt;
&lt;code&gt;SELECT&lt;/code&gt; syntax but supports no &lt;code&gt;JOIN&lt;/code&gt;, no &lt;code&gt;GROUP BY&lt;/code&gt;, no &lt;code&gt;DISTINCT&lt;/code&gt;, no&lt;br&gt;
&lt;code&gt;LIKE&lt;/code&gt; — and a model that learned SQL from the internet emits all of them&lt;br&gt;
confidently.&lt;/p&gt;

&lt;p&gt;The failures we actually observed, each from a real logged run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dropped &lt;code&gt;limit&lt;/code&gt; arguments.&lt;/strong&gt; &lt;a href="https://dynotable.com/docs/glossary#llm" rel="noopener noreferrer"&gt;Cheap models&lt;/a&gt; routinely omit the
optional row limit on a query tool. The backend happily returns everything, a
200 KB tool result lands in the conversation, and the next model turn
dies on the context ceiling — a &lt;code&gt;ValidationException&lt;/code&gt; mid-loop, blamed
on the wrong component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsupported constructs.&lt;/strong&gt; &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, aggregates in PartiQL —
statements DynamoDB will never run, emitted because they'd be correct
on Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identifier swaps.&lt;/strong&gt; Nova Lite, given &lt;code&gt;SELECT * FROM customers&lt;/code&gt; and the
instruction "add a WHERE for Germany", would sometimes return
&lt;code&gt;SELECT * FROM orders WHERE …&lt;/code&gt; — a table the user never mentioned. The
statement parses perfectly. Nothing syntactic catches it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool misrouting.&lt;/strong&gt; Asked about an attached file, Nova Lite called the
open-tabs listing tool looking for files in tabs — it rarely used the
tool-search mechanism at all, so anything not directly visible didn't
exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are hypothetical. Each one drove a specific guardrail below.&lt;/p&gt;
&lt;h2&gt;
  
  
  Validate at the tool boundary, and make the error a lesson
&lt;/h2&gt;

&lt;p&gt;The single most important architectural decision: &lt;strong&gt;validation lives inside&lt;br&gt;
the tool's execute step, not in the input schema.&lt;/strong&gt; A schema rejection is a&lt;br&gt;
dead end — the loop sees a type error and learns nothing. A tool that runs,&lt;br&gt;
checks, and returns a structured error gives the model a normal tool result&lt;br&gt;
it can react to on the next step.&lt;/p&gt;

&lt;p&gt;And that error is written for the model, not for a human log. Our export&lt;br&gt;
tool's errors read like this, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;startExport requires exactly one of {tabId} or {sql}.
FIX: call listOpenTabs and pass {tabId}, or pass {sql} with a single SELECT.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;startExport {sql} must be a read-only single SELECT.
FIX: remove any INSERT/UPDATE/DELETE/DDL and pass one SELECT statement.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Query validation failures carry a &lt;code&gt;validation:&lt;/code&gt; prefix —&lt;br&gt;
&lt;code&gt;validation: parse-error: …&lt;/code&gt;, &lt;code&gt;validation: partiql-unsupported: JOIN is not&lt;br&gt;
supported by DynamoDB PartiQL.&lt;/code&gt; — which the system prompt establishes as&lt;br&gt;
the signal to &lt;em&gt;fix the statement&lt;/em&gt; rather than retry the same input. A&lt;br&gt;
rejection message is a teaching signal. Before we adopted the &lt;code&gt;FIX:&lt;/code&gt;&lt;br&gt;
convention, the same failures ended with the model apologizing and telling&lt;br&gt;
the user to click the export button themselves. After, it self-corrects and&lt;br&gt;
completes the task.&lt;/p&gt;

&lt;p&gt;The corollary took longer to learn: &lt;strong&gt;where the concrete example lives is a&lt;br&gt;
design axis.&lt;/strong&gt; If a mistake is caught by a validator at runtime, keep the&lt;br&gt;
system prompt generic and let the dynamic error message carry the specific&lt;br&gt;
tables and fix — the prompt stays small and the lesson arrives exactly when&lt;br&gt;
it's needed. Only mistakes &lt;em&gt;no validator can catch&lt;/em&gt; (like routing an&lt;br&gt;
aggregate question to the right tool in the first place) earn a concrete&lt;br&gt;
example in the prompt itself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram: &lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models" rel="noopener noreferrer"&gt;view on dynotable.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Parse the things you're going to reject
&lt;/h2&gt;

&lt;p&gt;Rejecting &lt;code&gt;JOIN&lt;/code&gt; with a generic &lt;code&gt;syntax error at offset 27&lt;/code&gt; teaches&lt;br&gt;
nothing. So our hand-written PartiQL parser does something slightly&lt;br&gt;
perverse: it &lt;strong&gt;deliberately parses constructs DynamoDB doesn't support&lt;/strong&gt;&lt;br&gt;
into a valid syntax tree — just so a walker can emit a specific, teachable&lt;br&gt;
diagnostic. All verbatim, all model-facing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;JOIN is not supported by DynamoDB PartiQL.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GROUP BY is not supported by DynamoDB PartiQL.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TOP N is not supported. Use the API limit parameter.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IN list has 120 items. DynamoDB PartiQL caps IN at 50 values (PK column)
or 100 values (non-key column).&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IS NOT NULL is not supported in DynamoDB PartiQL. Use attribute_exists.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lower(path) is not a DynamoDB PartiQL function. Use … instead.&lt;/code&gt; — with
the replacement resolved per function.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each message names the construct &lt;em&gt;and&lt;/em&gt; the escape hatch. Paired with the&lt;br&gt;
prompt rule "if PartiQL rejects an unsupported construct, switch to the&lt;br&gt;
SQL workbench immediately", a cheap model recovers from its Postgres&lt;br&gt;
instincts in one step instead of thrashing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Know what NOT to validate
&lt;/h2&gt;

&lt;p&gt;The schema-aware tier of our SQL validator resolves table and column&lt;br&gt;
references against the real table descriptions the app already holds. Early&lt;br&gt;
on it also flagged references to attributes DynamoDB didn't declare — which&lt;br&gt;
sounds right and is exactly wrong. DynamoDB's &lt;code&gt;attributeDefinitions&lt;/code&gt; only&lt;br&gt;
lists &lt;strong&gt;key&lt;/strong&gt; attributes, so the check rejected &lt;code&gt;WHERE &amp;lt;non-key&amp;gt; = …&lt;/code&gt; — the&lt;br&gt;
single most common legitimate query shape in existence. That check is now a&lt;br&gt;
warning that never gates.&lt;/p&gt;

&lt;p&gt;Every validator is a bet that the rejected shape is more likely wrong than&lt;br&gt;
right. When the data model can't support the check, don't make the bet.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result caps as behavior, not just safety
&lt;/h2&gt;

&lt;p&gt;The fix for the dropped-&lt;code&gt;limit&lt;/code&gt; blow-up wasn't "add the limit to the&lt;br&gt;
prompt" (cheap models drop it anyway). Query tool results are capped hard —&lt;br&gt;
&lt;strong&gt;10 rows or 5 KB, whichever comes first&lt;/strong&gt;, always keeping at least the&lt;br&gt;
first row — and the truncation notice is itself prescriptive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Showing 10 of 4,200. To filter: re-run with WHERE.
To view in UI: emit proposeOpenTable.
To aggregate: use runWorkbenchSql GROUP BY.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caps are intentionally far below what the context could hold. The goal&lt;br&gt;
is behavioral: &lt;em&gt;force the model to hand the user a real table view or an&lt;br&gt;
exact aggregate, never to enumerate rows into chat.&lt;/em&gt; The recovery hint&lt;br&gt;
names the exact tools, so the cap teaches the routing at the moment the&lt;br&gt;
model needs it. The same envelope is mirrored byte-for-byte in the eval&lt;br&gt;
harness, and a regression scenario pins &lt;code&gt;truncated: true&lt;/code&gt; against a&lt;br&gt;
10,000-item table so the cap can never silently disappear.&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%2F7er65xf2zr52ehjryz6b.webp" 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%2F7er65xf2zr52ehjryz6b.webp" alt="Instead of enumerating rows into chat, the agent emits a chip you click to open the result as a real DynoTable tab." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Evals are the arbiter
&lt;/h2&gt;

&lt;p&gt;None of the above was designed on intuition and kept on faith. Every&lt;br&gt;
prompt clause, validator message, and cap is gated by an eval suite that&lt;br&gt;
runs &lt;strong&gt;real agent loops against a seeded DynamoDB Local&lt;/strong&gt; — real tools,&lt;br&gt;
real query execution, the production prompt builders and validators&lt;br&gt;
imported directly rather than reimplemented — scored by binary checks on&lt;br&gt;
the tool trace, the final state of the database, and the text.&lt;/p&gt;

&lt;p&gt;What the evals caught that intuition missed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A "cleaner" prompt genericization regressed a scenario from 100% to
0%.&lt;/strong&gt; It read better to us. The floor model disagreed. Only the eval
noticed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontier-model prompt advice is actively wrong for cheap models.&lt;/strong&gt;
Published guidance says to soften CRITICAL/MUST language because big
models over-trigger on it. Cheap models &lt;em&gt;under&lt;/em&gt;-comply and need the firm
version. We now eval-gate any borrowed advice instead of applying it on
faith.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strict tool assertions punished correct behavior.&lt;/strong&gt; A scorer requiring
the model to call the &lt;code&gt;ask&lt;/code&gt; tool for clarification passed 19% across the
model ladder — almost every "failure" was a model that had correctly
asked for clarification &lt;em&gt;in text&lt;/em&gt;. The scorer now accepts either. Score
the user-facing intent, not the mechanism.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model ladder earned its narrowing.&lt;/strong&gt; We started broad — Mistral,
AI21, GLM, Qwen, Nova Pro, more — and cut it to two rungs (Nova Lite,
Claude Haiku) for reasons only real runs surface: one family cost ~10×
per call because Bedrock offered it no prompt caching; Nova Pro
hallucinated concatenated attribute names like &lt;code&gt;customers.customerName&lt;/code&gt;
on open-ended joins. And one family had to be blocklisted outright —
Bedrock's unified API happily accepts tool definitions for models whose
adapter then emits function calls as &lt;em&gt;plain text&lt;/em&gt;, something the API
metadata won't tell you. We found it with a paid smoke probe, not a spec.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two disciplines keep the suite honest. &lt;strong&gt;Example tables never equal test&lt;br&gt;
tables&lt;/strong&gt;: prompt examples use neutral names that don't exist in the seed&lt;br&gt;
data, and the evals run on different tables — so a pass proves the model&lt;br&gt;
generalized the pattern, not that it memorized our example. And the &lt;strong&gt;$0&lt;br&gt;
watch mode refuses recorded fixtures&lt;/strong&gt;: replayed model outputs would&lt;br&gt;
green-pass assertions that depend on real tool activity, so the free tier&lt;br&gt;
validates wiring only, and anything that claims a &lt;em&gt;score&lt;/em&gt; had to spend&lt;br&gt;
real tokens on a real model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist, if you're building one of these
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pick your floor model first and eval against it always; frontier models
hide your bugs.&lt;/li&gt;
&lt;li&gt;Put validation in the tool's execute step; return errors the loop can
act on, never schema dead-ends.&lt;/li&gt;
&lt;li&gt;Write every rejection as &lt;code&gt;what's wrong&lt;/code&gt; + &lt;code&gt;FIX: exact next action&lt;/code&gt;, and
interpolate the user's actual tables into it.&lt;/li&gt;
&lt;li&gt;Parse what you intend to reject, so the diagnostic can be specific.&lt;/li&gt;
&lt;li&gt;Audit each validator against your data model — delete the ones that
reject legitimate shapes.&lt;/li&gt;
&lt;li&gt;Cap tool results below what fits, and make the truncation notice route
the model to the right next tool.&lt;/li&gt;
&lt;li&gt;Run evals as real loops against a real (local) backend, reusing
production code paths; keep example data disjoint from test data; make
the eval — not taste — the arbiter of every prompt and message change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does any of this matter if you use a frontier model?
&lt;/h2&gt;

&lt;p&gt;Less often, but yes. The validators are load-bearing at every tier — a&lt;br&gt;
frontier model still can't run &lt;code&gt;JOIN&lt;/code&gt; on DynamoDB, and prescriptive errors&lt;br&gt;
just get consumed faster. On our own runs the chip-emission behavior of a&lt;br&gt;
&lt;em&gt;frontier-class&lt;/em&gt; model once dropped to 0% under a prompt phrasing that&lt;br&gt;
smaller models handled — the eval harness caught that too. Reliability&lt;br&gt;
engineering isn't a small-model tax; small models just make the invoice&lt;br&gt;
arrive sooner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this runs
&lt;/h2&gt;

&lt;p&gt;All of this ships inside &lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;DynoTable&lt;/a&gt;'s agent and its&lt;br&gt;
&lt;a href="https://dynotable.com/docs/ai-tools" rel="noopener noreferrer"&gt;gated tool catalog&lt;/a&gt;: schema-aware&lt;br&gt;
&lt;a href="https://dynotable.com/learn/query-dynamodb-with-ai" rel="noopener noreferrer"&gt;natural-language querying&lt;/a&gt; on your own&lt;br&gt;
Bedrock credentials, exact whole-table aggregates, and writes that only&lt;br&gt;
ever land in a reviewable staging area. External agents get the same&lt;br&gt;
validated toolkit through the&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-mcp-server" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; — how we built &lt;em&gt;that&lt;/em&gt; safely is&lt;br&gt;
its own story, from OAuth to credential isolation.&lt;/p&gt;

&lt;p&gt;And when you want determinism instead of generation — the query you'll&lt;br&gt;
commit to code — skip the model entirely: the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;Expression Builder&lt;/a&gt; assembles the&lt;br&gt;
exact request by hand.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>How we built a local SQL engine for DynamoDB (a database with no JOINs)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Fri, 17 Jul 2026 20:30:00 +0000</pubDate>
      <link>https://dev.to/dynotable/how-we-built-a-local-sql-engine-for-dynamodb-a-database-with-no-joins-1o9d</link>
      <guid>https://dev.to/dynotable/how-we-built-a-local-sql-engine-for-dynamodb-a-database-with-no-joins-1o9d</guid>
      <description>&lt;p&gt;DynamoDB can export a 100 GB table but it cannot aggregate one. There is no&lt;br&gt;
&lt;code&gt;JOIN&lt;/code&gt;, no &lt;code&gt;GROUP BY&lt;/code&gt;, no &lt;code&gt;COUNT(*)&lt;/code&gt; — not slow versions of them, &lt;em&gt;none&lt;/em&gt; —&lt;br&gt;
and even &lt;a href="https://dynotable.com/docs/glossary#partiql" rel="noopener noreferrer"&gt;PartiQL&lt;/a&gt;, AWS's own SQL-ish surface, &lt;a href="https://dynotable.com/learn/partiql-vs-sql" rel="noopener noreferrer"&gt;doesn't add&lt;br&gt;
them&lt;/a&gt;. So every team ends up writing the same&lt;br&gt;
throwaway script: paginate a &lt;a href="https://dynotable.com/docs/glossary#scan" rel="noopener noreferrer"&gt;Scan&lt;/a&gt;, fold rows into a map, print the&lt;br&gt;
answer, delete the script.&lt;/p&gt;

&lt;p&gt;DynoTable's &lt;a href="https://dynotable.com/docs/workbench" rel="noopener noreferrer"&gt;Workbench&lt;/a&gt; is our answer to that script: a tab&lt;br&gt;
where you write one real &lt;code&gt;SELECT&lt;/code&gt; — multi-table &lt;code&gt;JOIN … ON&lt;/code&gt;, &lt;code&gt;WHERE&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;HAVING&lt;/code&gt;, &lt;code&gt;DISTINCT&lt;/code&gt;, &lt;code&gt;CASE&lt;/code&gt;, aggregates — against tables that&lt;br&gt;
support none of it. This post is how it works under the hood, the parser&lt;br&gt;
that silently lied to us, and why we later tore out the engine's memory&lt;br&gt;
model.&lt;/p&gt;
&lt;h2&gt;
  
  
  Don't build a query engine
&lt;/h2&gt;

&lt;p&gt;The core decision was refusing to write a relational executor. DynamoDB&lt;br&gt;
pages stream into an embedded SQLite database, and SQLite — one of the most&lt;br&gt;
tested query engines on earth — does the relational work:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram: &lt;a href="https://dynotable.com/blog/local-sql-engine-for-dynamodb" rel="noopener noreferrer"&gt;view on dynotable.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each DynamoDB item lands in SQLite as its raw typed envelope&lt;br&gt;
(&lt;code&gt;{platform: {S: "ios"}}&lt;/code&gt;) in a single &lt;code&gt;item&lt;/code&gt; column. A small user-defined&lt;br&gt;
function unwraps attributes at query time, so your &lt;code&gt;SELECT platform&lt;/code&gt;&lt;br&gt;
compiles to a &lt;code&gt;attr(item, 'platform', …)&lt;/code&gt; call. One detail in that&lt;br&gt;
function is load-bearing: it wraps its JSON parsing in a catch, because the&lt;br&gt;
SQLite binding turns one malformed row's parse error into an abort of the&lt;br&gt;
&lt;em&gt;entire&lt;/em&gt; prepared statement. Without the catch, one corrupt row would kill&lt;br&gt;
the query.&lt;/p&gt;

&lt;p&gt;The honest constraint we kept: &lt;strong&gt;DynamoDB's access-pattern physics still&lt;br&gt;
apply.&lt;/strong&gt; A &lt;code&gt;JOIN&lt;/code&gt;'s to-side must be a primary key or a &lt;a href="https://dynotable.com/docs/glossary#gsi" rel="noopener noreferrer"&gt;GSI&lt;/a&gt;&lt;br&gt;
partition key — the engine resolves joins by key lookup against the&lt;br&gt;
stream, never by cross-product scan. If you need arbitrary joins on&lt;br&gt;
non-key attributes, that's a &lt;a href="https://dynotable.com/learn/dynamodb-join" rel="noopener noreferrer"&gt;modeling conversation&lt;/a&gt;,&lt;br&gt;
not a query-engine feature.&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%2Ftqbdzcoid4t8eucnyrml.webp" 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%2Ftqbdzcoid4t8eucnyrml.webp" alt="The Workbench: a multi-table SQL JOIN, with the joined result grid below." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The parser that lied to us
&lt;/h2&gt;

&lt;p&gt;The first version used a popular off-the-shelf SQL parser. Over a few&lt;br&gt;
weeks, four independent correctness bugs traced back to it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SELECT DISTINCT&lt;/code&gt; was silently dropped.&lt;/strong&gt; The parser accepted it and
the emitter ignored it — every duplicate row came back. A user's bug
report read: "shows a table of all the values, not counters."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything unhandled became the literal SQL &lt;code&gt;NULL&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;CASE&lt;/code&gt;, &lt;code&gt;CAST&lt;/code&gt;,
scalar subqueries — the emitter's fallback for an unknown syntax node
was &lt;code&gt;return 'NULL'&lt;/code&gt;. Queries ran, returned confident wrong answers, and
raised no error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identifier case didn't behave like SQL.&lt;/strong&gt; &lt;code&gt;SELECT PLATFORM FROM t&lt;/code&gt;
returned nulls when the attribute was &lt;code&gt;platform&lt;/code&gt; — the opposite of what
every SQL database trains you to expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autocomplete disagreed with the resolver&lt;/strong&gt;, suggesting names the
compiler would then fail to resolve.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The replacement, &lt;code&gt;sql-parser-cst&lt;/code&gt;, won on one primitive the survey found&lt;br&gt;
nowhere else: its tree preserves both the &lt;strong&gt;raw text&lt;/strong&gt; and the&lt;br&gt;
&lt;strong&gt;normalized name&lt;/strong&gt; of every identifier — so the compiler can tell&lt;br&gt;
&lt;code&gt;platform&lt;/code&gt; from &lt;code&gt;"PLATFORM"&lt;/code&gt;. That single distinction lets one grammar&lt;br&gt;
carry proper ANSI semantics: unquoted identifiers resolve&lt;br&gt;
case-insensitively, quoted ones are the case-sensitive escape hatch,&lt;br&gt;
exactly like Postgres or SQLite. It also carries a source range on every&lt;br&gt;
node, so a diagnostic underlines the offending construct instead of the&lt;br&gt;
whole statement.&lt;/p&gt;

&lt;p&gt;And we replaced the &lt;code&gt;return 'NULL'&lt;/code&gt; fallback with a rule we now treat as&lt;br&gt;
policy: &lt;strong&gt;no silent NULL, ever.&lt;/strong&gt; The emitter's default arm is a&lt;br&gt;
precise-ranged error. Window functions get a message that says why, not&lt;br&gt;
just no:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade we accepted: a pre-1.0 dependency, pinned to the exact version,&lt;br&gt;
upgraded only by hand with the test suite as the gate. We evaluated&lt;br&gt;
patching the old parser (it needed a parallel pre-tokenizer — parsing&lt;br&gt;
every input twice), using the editor's syntax tree (token-level, no clause&lt;br&gt;
structure), and hand-rolling (most of a SQLite parser, unbounded&lt;br&gt;
maintenance). A four-way correctness failure beats all of those concerns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing what not to block
&lt;/h2&gt;

&lt;p&gt;One diagnostic deliberately does &lt;em&gt;not&lt;/em&gt; stop you. When a filter references&lt;br&gt;
an attribute the schema can't confirm the casing of, we can't know the&lt;br&gt;
canonical case — DynamoDB's server-side filters are case-sensitive and its&lt;br&gt;
schema only declares key attributes. That emits a warning, and warnings&lt;br&gt;
never disable Run. We learned this class of lesson the hard way in&lt;br&gt;
&lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models" rel="noopener noreferrer"&gt;our validator work&lt;/a&gt;: a gate that&lt;br&gt;
rejects the most common legitimate query shape is worse than no gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cap was the wrong shape
&lt;/h2&gt;

&lt;p&gt;The first aggregation engine buffered scanned rows into an &lt;strong&gt;in-memory&lt;/strong&gt;&lt;br&gt;
SQLite with a size cap — 64 MB here, 250 MB there, and one path with no&lt;br&gt;
cap that could OOM the app on a big table. Hit the cap and you got a&lt;br&gt;
partial answer with an overflow flag.&lt;/p&gt;

&lt;p&gt;The redesign started with a reframing: &lt;strong&gt;the competitor is the throwaway&lt;br&gt;
script a developer would write instead.&lt;/strong&gt; And a competent script doesn't&lt;br&gt;
buffer-then-cap — it &lt;em&gt;streams&lt;/em&gt;, folding each page into a running total&lt;br&gt;
with bounded memory. The in-memory cap wasn't too small; it was the wrong&lt;br&gt;
shape. Worse, a partial aggregate isn't "incomplete" — it's &lt;strong&gt;wrong&lt;/strong&gt;. The&lt;br&gt;
moment that made this concrete: our own AI demo confidently narrated a&lt;br&gt;
partial "top spenders" ranking as fact.&lt;/p&gt;

&lt;p&gt;The rebuilt Compute engine works the way the script does, plus everything&lt;br&gt;
the script never bothers with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A planner classifies every query&lt;/strong&gt; into one of three lanes:
&lt;em&gt;stream-fold&lt;/em&gt; (plain algebraic aggregates — &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;/&lt;code&gt;MAX&lt;/code&gt; — folded page-by-page off the main process; size never
gates a foldable query, it just takes longer), &lt;em&gt;materialize&lt;/em&gt; (sorts,
key-bounded joins — spilled to disk-backed SQLite, no memory cap), or
an honest &lt;em&gt;refusal&lt;/em&gt; that recommends the right heavyweight tool
(DynamoDB's S3 export plus Athena) for the tail we can't serve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fold classification is deliberately narrow.&lt;/strong&gt; A &lt;code&gt;HAVING&lt;/code&gt;, an
&lt;code&gt;ORDER BY&lt;/code&gt;, a bare column next to the aggregate — any of them forces
the materialize lane. A fold that ignored a clause and still reported
"exact" would be precisely the wrong-but-confident bug this planner
exists to prevent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exactness is part of the result.&lt;/strong&gt; Aggregate columns show a &lt;em&gt;partial&lt;/em&gt;
badge while pages are still streaming, and drop it only when the scan
has drained. Even arithmetic honesty is tracked: integer sums stay
exact past 2⁵³ (stored as 64-bit integers), while a fractional sum that
exceeds the float mantissa flags itself &lt;code&gt;partial: precision&lt;/code&gt; rather than
round quietly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuses replace popups for machines.&lt;/strong&gt; The interactive app asks before a
five-million-item scan; the &lt;a href="https://dynotable.com/blog/building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;AI agent and MCP
callers&lt;/a&gt; get scan budgets and
page-boundary aborts instead. And the result envelope the model sees is
deliberately trimmed — cost class and size estimates are withheld,
because surfacing a stale guess invites the model to narrate it as fact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One cost note that surprised people internally: for a one-off aggregate, a&lt;br&gt;
live scan is roughly &lt;strong&gt;6× cheaper&lt;/strong&gt; than generating a DynamoDB→S3 export —&lt;br&gt;
which is why the script-shaped lane is the default and the warehouse is&lt;br&gt;
the recommendation for repeated heavy analytics, not the reflex. (The&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt; will price a full&lt;br&gt;
pass of your own table.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it safe to point SQL at production?
&lt;/h2&gt;

&lt;p&gt;The Workbench is structurally read-only. The final SQL that reaches the&lt;br&gt;
local engine passes a defense-in-depth gate that tokenizes and rejects&lt;br&gt;
anything that isn't a single &lt;code&gt;SELECT&lt;/code&gt; — as a &lt;em&gt;contract violation&lt;/em&gt;, not a&lt;br&gt;
user error, because the compiler should never have produced it. The SQLite&lt;br&gt;
substrate runs with dangerous built-ins disabled, and writes to DynamoDB&lt;br&gt;
have exactly one path in the entire app: the&lt;br&gt;
&lt;a href="https://dynotable.com/docs/staging" rel="noopener noreferrer"&gt;reviewable staging area&lt;/a&gt;, which SQL cannot reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  What transfers if you're building one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't write a query engine; write a faithful &lt;em&gt;compiler&lt;/em&gt; onto one that
already exists. Your bugs will live in the seams (identifier case, type
envelopes, corrupt rows), not the join algorithm.&lt;/li&gt;
&lt;li&gt;Demand quote-provenance from your parser. If it can't tell &lt;code&gt;name&lt;/code&gt; from
&lt;code&gt;"NAME"&lt;/code&gt;, you cannot implement SQL's case semantics, and your users
will find out before you do.&lt;/li&gt;
&lt;li&gt;Make "unhandled syntax" a loud error, never a default value. Silent
&lt;code&gt;NULL&lt;/code&gt; is how confident wrong answers ship.&lt;/li&gt;
&lt;li&gt;Benchmark your design against the throwaway script your user would
write. If the script streams and you buffer, you built the wrong shape.&lt;/li&gt;
&lt;li&gt;Treat partial aggregates as wrong answers that need labeling, not
partial credit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://dynotable.com/docs/workbench" rel="noopener noreferrer"&gt;Workbench docs&lt;/a&gt; cover the day-to-day feature surface,&lt;br&gt;
and &lt;a href="https://dynotable.com/learn/sql-for-dynamodb" rel="noopener noreferrer"&gt;SQL for DynamoDB&lt;/a&gt; maps what works across the&lt;br&gt;
whole ecosystem. Or just &lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt;, open a Workbench&lt;br&gt;
tab with ⌘⌥Q, and run a &lt;code&gt;JOIN&lt;/code&gt; against the database that&lt;br&gt;
doesn't have one.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>Why a database GUI should never write directly</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Fri, 17 Jul 2026 20:29:59 +0000</pubDate>
      <link>https://dev.to/dynotable/why-a-database-gui-should-never-write-directly-535p</link>
      <guid>https://dev.to/dynotable/why-a-database-gui-should-never-write-directly-535p</guid>
      <description>&lt;p&gt;Every database GUI has a moment where a click turns into a write against&lt;br&gt;
production. We decided that moment shouldn't exist. In DynoTable, nothing&lt;br&gt;
you do — not an item edit, not a bulk delete, not an&lt;br&gt;
&lt;a href="https://dynotable.com/docs/glossary#ai-agent" rel="noopener noreferrer"&gt;AI agent&lt;/a&gt;'s change — touches DynamoDB directly. Every write&lt;br&gt;
lands in a &lt;a href="https://dynotable.com/docs/glossary#staging-area" rel="noopener noreferrer"&gt;staging area&lt;/a&gt; as a reviewable per-attribute&lt;br&gt;
diff, and ships only when a human commits it.&lt;/p&gt;

&lt;p&gt;The mental model we built toward is git: a staging area for review, and a&lt;br&gt;
commit that behaves like &lt;code&gt;git push --force-with-lease&lt;/code&gt; — it succeeds only&lt;br&gt;
if the remote still looks the way you last saw it. This post is the&lt;br&gt;
engineering underneath: the cross-tab bug that reshaped the whole design,&lt;br&gt;
the refactor whose main output was &lt;em&gt;deleted code&lt;/em&gt;, and why the arrival of&lt;br&gt;
AI agents turned a UX nicety into the load-bearing safety wall.&lt;/p&gt;
&lt;h2&gt;
  
  
  Writes are diffs
&lt;/h2&gt;

&lt;p&gt;Edits accumulate in a local SQLite-backed stage and render as diff cards —&lt;br&gt;
old value, new value, per attribute — in a side panel. Rows tint in the&lt;br&gt;
grid so staged state is visible from the data, not just the panel. Commit&lt;br&gt;
ships the staged set as DynamoDB &lt;a href="https://dynotable.com/docs/glossary#transaction" rel="noopener noreferrer"&gt;transactions&lt;/a&gt;, chunked to&lt;br&gt;
the service's limits (100 items per transaction, with byte budgets per&lt;br&gt;
operation and per request), sequentially, with a 30-second timeout per&lt;br&gt;
chunk.&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%2Fjt152cafcesdaaqt1c2w.webp" 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%2Fjt152cafcesdaaqt1c2w.webp" alt="The staging panel showing pending changes as per-attribute diff cards, with per-row and bulk commit." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dynotable.com/docs/staging" rel="noopener noreferrer"&gt;staging docs&lt;/a&gt; cover the daily workflow. What they don't&lt;br&gt;
cover is what the stage is &lt;em&gt;keyed by&lt;/em&gt; — which turned out to be the most&lt;br&gt;
consequential decision in the system.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keyed to the table, not the tab
&lt;/h2&gt;

&lt;p&gt;The first version scoped each stage to the tab that created it. That&lt;br&gt;
design produced a real bug: the AI's staging tool derived its target from&lt;br&gt;
the &lt;em&gt;active tab&lt;/em&gt;, and its guard skipped tabs that aren't table views. So&lt;br&gt;
if you asked the assistant to fix a row while a SQL Workbench tab was&lt;br&gt;
focused, the edit was staged into the Workbench's stage — and the "reveal&lt;br&gt;
staged change" chip opened the wrong tab.&lt;/p&gt;

&lt;p&gt;Worse, the commit lock was also per-tab. Two tabs viewing the &lt;em&gt;same table&lt;/em&gt;&lt;br&gt;
held two independent locks — meaning both could commit the same staged&lt;br&gt;
rows to real DynamoDB concurrently. A double-commit race against&lt;br&gt;
production, built into the data model.&lt;/p&gt;

&lt;p&gt;The fix was to re-key everything by &lt;strong&gt;table identity&lt;/strong&gt; —&lt;br&gt;
&lt;code&gt;{profile, region, tableName}&lt;/code&gt; — instead of by tab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One stage per table, visible from every view of it, surviving tab
close and reopen. The AI can stage with no table tab open at all.&lt;/li&gt;
&lt;li&gt;One commit lock per table. The double-commit race is not "handled" —
it's unrepresentable.&lt;/li&gt;
&lt;li&gt;The wrong-tab bug class is gone &lt;em&gt;by construction&lt;/em&gt;: there is no tab in
the key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And re-keying deleted more code than it added. The startup sweep that&lt;br&gt;
garbage-collected stages for closed tabs? A reviewer flagged it as&lt;br&gt;
feature-destroying under the new model — stages no longer die with tabs —&lt;br&gt;
so it was removed outright. Discard-on-tab-close and its confirmation&lt;br&gt;
dialog: removed. The only true orphan left is a deleted connection&lt;br&gt;
profile, which cascades explicitly.&lt;/p&gt;

&lt;p&gt;The migration itself was the repo's first row-&lt;em&gt;mutating&lt;/em&gt; one: a&lt;br&gt;
hand-written SQLite table rebuild that de-duplicated legacy per-tab rows&lt;br&gt;
with a &lt;code&gt;ROW_NUMBER() OVER (PARTITION BY …)&lt;/code&gt; window (newest edit wins) and&lt;br&gt;
backfilled the new key with &lt;code&gt;char(0)&lt;/code&gt; separators, byte-identical to the&lt;br&gt;
runtime's key builder. Row-mutating migrations got their own&lt;br&gt;
seed-then-migrate test harness that day.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;code&gt;--force-with-lease&lt;/code&gt;, for DynamoDB
&lt;/h2&gt;

&lt;p&gt;A review UI is worthless if the thing you reviewed isn't the thing that&lt;br&gt;
gets written. Between staging and committing, someone else may have&lt;br&gt;
changed the row. So every commit operation carries&lt;br&gt;
&lt;a href="https://dynotable.com/docs/glossary#condition-expression" rel="noopener noreferrer"&gt;condition expressions&lt;/a&gt; that pin the write to the&lt;br&gt;
exact snapshot you reviewed — &lt;a href="https://dynotable.com/docs/glossary#optimistic-locking" rel="noopener noreferrer"&gt;optimistic locking&lt;/a&gt;,&lt;br&gt;
attribute by attribute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;create&lt;/strong&gt; asserts the item doesn't already exist.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;update&lt;/strong&gt; asserts every attribute you're changing still holds the
value you saw when you staged it.&lt;/li&gt;
&lt;li&gt;Even a &lt;strong&gt;removal&lt;/strong&gt; asserts the attribute still equals its old value —
if a teammate changed &lt;code&gt;note&lt;/code&gt; from &lt;code&gt;"old"&lt;/code&gt; to &lt;code&gt;"new"&lt;/code&gt; while your removal
of &lt;code&gt;note&lt;/code&gt; sat staged, the commit must not silently delete their edit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a condition fails, DynamoDB cancels the whole transaction and tells&lt;br&gt;
us which item drifted. That row gets a conflict banner — rebase against&lt;br&gt;
the live value or abort — rather than a silent overwrite in either&lt;br&gt;
direction.&lt;/p&gt;

&lt;p&gt;And on any chunk failure, the committer &lt;strong&gt;halts&lt;/strong&gt;. Committed chunks stay&lt;br&gt;
committed, the failed chunk rolls back atomically, nothing further is&lt;br&gt;
attempted. We deliberately rejected best-effort continuation: a review&lt;br&gt;
tool that keeps writing past a conflict is applying a change set nobody&lt;br&gt;
reviewed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram: &lt;a href="https://dynotable.com/blog/database-gui-staged-writes" rel="noopener noreferrer"&gt;view on dynotable.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The refactor that deleted a subsystem
&lt;/h2&gt;

&lt;p&gt;Commits are long-running: the app registers each one in a replay registry&lt;br&gt;
so a renderer reload — or a second window — can re-attach and watch it&lt;br&gt;
finish. Over time, three different code paths each attached &lt;em&gt;two&lt;/em&gt;&lt;br&gt;
observers per commit, and an entire arbitration mechanism grew up around&lt;br&gt;
one question: which observer is allowed to release the commit lock? It had&lt;br&gt;
its own scary name (&lt;code&gt;ownsLockOnBeforeRegistration&lt;/code&gt;), a comment block&lt;br&gt;
explaining an IPC ordering race, and a 230-line toast tracker.&lt;/p&gt;

&lt;p&gt;The fix was a single owner: a &lt;strong&gt;Commit Session&lt;/strong&gt; that attaches exactly&lt;br&gt;
once per commit, owns the lock exclusively, projects progress into the&lt;br&gt;
store, and guarantees its start call settles on every exit path — never&lt;br&gt;
hangs, never rejects. The arbitration machinery and the tracker weren't&lt;br&gt;
relocated; they were deleted. The memory benchmark that thrashes the&lt;br&gt;
staging panel dropped about a third of its heap. The best code review&lt;br&gt;
comment we got that month: "this PR is mostly red."&lt;/p&gt;

&lt;p&gt;Two behaviors fall out of that ownership model that we consider&lt;br&gt;
table-stakes for any tool that writes to production: an in-flight commit&lt;br&gt;
&lt;strong&gt;survives a renderer reload&lt;/strong&gt; (the session re-attaches and completes),&lt;br&gt;
and it even survives your license flipping to read-only mid-commit — new&lt;br&gt;
commits are blocked, but a write already in flight is observed to its end,&lt;br&gt;
never abandoned half-applied.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the wire format is deliberately ugly
&lt;/h2&gt;

&lt;p&gt;Staged values are stored as raw DynamoDB typed envelopes —&lt;br&gt;
&lt;code&gt;{N: "42"}&lt;/code&gt;, &lt;code&gt;{S: "42"}&lt;/code&gt; — not as friendly unmarshalled JSON. Two reasons.&lt;br&gt;
The diff must be &lt;em&gt;type-aware&lt;/em&gt;: &lt;code&gt;{N: "1"}&lt;/code&gt; and &lt;code&gt;{S: "1"}&lt;/code&gt; are different&lt;br&gt;
values, and a primary-key hash built on unmarshalled values would collide&lt;br&gt;
them. And the round-trip must be lossless: the SDK's unmarshalled number&lt;br&gt;
wrappers don't survive JSON serialization into SQLite and back, and&lt;br&gt;
deep-equality against user input breaks. The commit path uses the raw&lt;br&gt;
client for the same reason — what you reviewed is byte-for-byte what gets&lt;br&gt;
conditioned on and written.&lt;/p&gt;
&lt;h2&gt;
  
  
  Then the agents arrived
&lt;/h2&gt;

&lt;p&gt;Staging predates our AI features, but it's the reason they could ship at&lt;br&gt;
all. The assistant's write tool stages; its own description tells the&lt;br&gt;
model — verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The user reviews + commits from the staging panel —
this tool never writes to DynamoDB directly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no commit tool. Not permission-gated, not audited — &lt;em&gt;absent&lt;/em&gt;.&lt;br&gt;
The &lt;a href="https://dynotable.com/blog/building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; exposes the same&lt;br&gt;
boundary structurally: its middle consent scope is literally named&lt;br&gt;
"read + stage". A &lt;a href="https://dynotable.com/docs/glossary#prompt-injection" rel="noopener noreferrer"&gt;prompt-injected&lt;/a&gt; agent at that scope&lt;br&gt;
can propose garbage, and the blast radius is a diff card a human reads.&lt;br&gt;
And because commits carry per-attribute optimistic locks, even a stale&lt;br&gt;
agent edit can't silently clobber a concurrent human one — it surfaces as&lt;br&gt;
a conflict like everything else.&lt;/p&gt;

&lt;p&gt;We wrote about &lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models" rel="noopener noreferrer"&gt;making the agent's queries reliable&lt;/a&gt;;&lt;br&gt;
staging is the other half — making its &lt;em&gt;writes&lt;/em&gt; boring.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to demand from any tool that writes to your database
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A review step between intent and write — diffs, not confirmation
dialogs.&lt;/li&gt;
&lt;li&gt;Optimistic concurrency on the &lt;em&gt;reviewed snapshot&lt;/em&gt;, including deletes
and removals — never last-writer-wins.&lt;/li&gt;
&lt;li&gt;Atomic batches with halt-on-failure, not best-effort continuation past
a conflict.&lt;/li&gt;
&lt;li&gt;A write path that survives a crash or reload without leaving a
half-applied set.&lt;/li&gt;
&lt;li&gt;For AI: staging as the &lt;em&gt;only&lt;/em&gt; write primitive an agent has — a missing
capability beats a guarded one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://dynotable.com/docs/staging" rel="noopener noreferrer"&gt;staging docs&lt;/a&gt; show the workflow, and&lt;br&gt;
&lt;a href="https://dynotable.com/learn/view-edit-dynamodb-data" rel="noopener noreferrer"&gt;editing DynamoDB data&lt;/a&gt; covers the basics&lt;br&gt;
it protects. Or &lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt;, stage a few edits with&lt;br&gt;
⌘S, and watch a bulk change become something you can actually&lt;br&gt;
read before it happens.&lt;/p&gt;

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