<?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>Why we hand-wrote a PartiQL parser for DynamoDB</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sat, 15 Aug 2026 16:22:30 +0000</pubDate>
      <link>https://dev.to/dynotable/why-we-hand-wrote-a-partiql-parser-for-dynamodb-587b</link>
      <guid>https://dev.to/dynotable/why-we-hand-wrote-a-partiql-parser-for-dynamodb-587b</guid>
      <description>&lt;p&gt;DynamoDB accepts a narrow slice of &lt;a href="https://dynotable.com/docs/dynamodb-glossary#partiql" rel="noopener noreferrer"&gt;PartiQL&lt;/a&gt; and rejects everything else at request time. &lt;code&gt;GROUP BY&lt;/code&gt;? &lt;code&gt;ValidationException&lt;/code&gt;. A statement-level &lt;code&gt;LIMIT&lt;/code&gt;? &lt;code&gt;ValidationException&lt;/code&gt;. The &lt;code&gt;*&lt;/code&gt; operator, &lt;code&gt;CAST&lt;/code&gt;, a subquery? All of them parse fine in your head, travel over the wire, and die on the server. The only place that knowledge lived was the AWS documentation and the error messages, which meant every editor for DynamoDB — including ours, for a while — would happily let you compose a statement the engine was guaranteed to refuse.&lt;/p&gt;

&lt;p&gt;We wanted the refusal to happen in the editor, on the keystroke, with a red squiggle on the exact clause and a one-click fix where a rewrite exists. That editor need turned into a hand-written lexer and CST parser for DynamoDB's PartiQL dialect, and this week we open-sourced it: &lt;a href="https://github.com/dynotable/dynamodb-partiql-parser" rel="noopener noreferrer"&gt;dynamodb-partiql-parser&lt;/a&gt;, pure TypeScript, zero dependencies, MIT, with the CodeMirror wiring published separately as &lt;a href="https://github.com/dynotable/codemirror-lang-partiql" rel="noopener noreferrer"&gt;codemirror-lang-partiql&lt;/a&gt;. This post is why it's hand-written, what the first linter got wrong, and the two bugs that only showed up when someone pasted garbage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regex was fine, until it wasn't
&lt;/h2&gt;

&lt;p&gt;The first PartiQL linter in DynoTable was about 650 lines of regex and token scanning, and it was genuinely useful: nineteen distinct checks, quick fixes for the common traps (&lt;code&gt;IN (...)&lt;/code&gt; to &lt;code&gt;[...]&lt;/code&gt;, &lt;code&gt;LIKE&lt;/code&gt; to &lt;code&gt;contains()&lt;/code&gt;, &lt;code&gt;IS NULL&lt;/code&gt; to &lt;code&gt;attribute_not_exists()&lt;/code&gt;). It shipped, it caught real mistakes, users stopped filing "why does my query fail" tickets for the cases it covered.&lt;/p&gt;

&lt;p&gt;But a regex linter knows patterns, not structure. It couldn't see that &lt;code&gt;*&lt;/code&gt; in &lt;code&gt;SELECT price * quantity&lt;/code&gt; is arithmetic DynamoDB rejects, because &lt;code&gt;*&lt;/code&gt; also means "all columns" and telling those apart requires actually parsing. Its diagnostic ranges were approximations — close enough to point at a line, too coarse to drive a quick fix that splices text at exact offsets. And every new check made the pile more fragile, because each regex had to defend against every other regex's assumptions.&lt;/p&gt;

&lt;p&gt;The fix for "the linter needs structure" is a parser. The question was which one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody had built one
&lt;/h2&gt;

&lt;p&gt;For the Workbench's real-SQL side we had already &lt;a href="https://dynotable.com/blog/local-sql-engine-for-dynamodb" rel="noopener noreferrer"&gt;been through this&lt;/a&gt;: an off-the-shelf SQL parser that lied to us, replaced by &lt;code&gt;sql-parser-cst&lt;/code&gt;, which carries a source range on every node and preserves quoted-versus-unquoted identifiers. That experience set the bar for what the PartiQL side needed — a lossless concrete syntax tree, not a lossy AST.&lt;/p&gt;

&lt;p&gt;But PartiQL is not SQL where it counts for a parser. DynamoDB's dialect writes &lt;code&gt;IN&lt;/code&gt; lists with brackets (&lt;code&gt;WHERE OrderID IN [100, 300, 234]&lt;/code&gt;), has bag literals (&lt;code&gt;&amp;lt;&amp;lt;'a', 'b'&amp;gt;&amp;gt;&lt;/code&gt;), map literals with quoted keys (&lt;code&gt;{'rating': 5}&lt;/code&gt;), a &lt;code&gt;MISSING&lt;/code&gt; literal, document paths with list indexes (&lt;code&gt;Devices.FireStick.DateWatched[0]&lt;/code&gt;), and &lt;code&gt;RETURNING ALL OLD *&lt;/code&gt; — none of which a SQL grammar knows. In the other direction it lacks half of what a SQL grammar insists on. At the time, the parsers on npm were WebAssembly builds of AWS's Rust implementation for generic PartiQL, with no notion of what DynamoDB specifically rejects.&lt;/p&gt;

&lt;p&gt;So we wrote one: a small lexer and a recursive-descent parser, modeled on the shape &lt;code&gt;sql-parser-cst&lt;/code&gt; taught us to want. Every node carries its byte range. The whole thing has zero runtime dependencies — a property the CI now asserts, because it's what makes the parser embeddable anywhere, including the browser, including your project.&lt;/p&gt;

&lt;p&gt;The grammar was the easy half. A linter's parser spends its whole life parsing &lt;em&gt;broken&lt;/em&gt; code. Mid-keystroke, half a statement, a typo in the third clause. Stopping at the first error would make the editor useless, so the parser is error-tolerant: it records a diagnostic, resynchronizes, and keeps going, so the fourth clause still lints while the second is incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Swapping the engine without breaking the plane
&lt;/h2&gt;

&lt;p&gt;By the time the parser was ready, the regex linter's four functions were load-bearing across the editor — including the one that decides whether a statement is safe to auto-execute. Silently changing that behavior shows up as "the editor won't run my query," which is the kind of bug users don't report so much as leave over.&lt;/p&gt;

&lt;p&gt;So the swap was a strangler: the old linter was renamed, frozen, and kept in the tree. The new parser-driven linter re-exported the exact same four functions. And a parity corpus ran every fixture through &lt;em&gt;both&lt;/em&gt; linters and pinned the outputs against each other — every diagnostic the regex version produced, the parser version had to produce too, before it was allowed to produce more. The old linter is still there today, frozen, as executable documentation of what the swap promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs that only garbage finds
&lt;/h2&gt;

&lt;p&gt;Two failures never appeared in any real query and both would have taken the editor down.&lt;/p&gt;

&lt;p&gt;A CodeMirror linter runs synchronously on the document, on every change, with no error sink above it. One uncaught exception doesn't fail a lint — it white-screens the editor. And a recursive-descent parser has a natural uncaught exception built in: the call stack. Paste &lt;code&gt;[[[[[[…&lt;/code&gt; a few thousand brackets deep, or a &lt;code&gt;NOT NOT NOT …&lt;/code&gt; chain, and each nesting level is a stack frame; V8 eventually throws &lt;code&gt;RangeError: Maximum call stack size exceeded&lt;/code&gt; straight through the linter.&lt;/p&gt;

&lt;p&gt;The fixes are boring on purpose. Expression recursion has a hard depth ceiling — five hundred levels, far beyond anything a human writes, well under the stack budget — past which the parser emits a single diagnostic instead of throwing. And the constructs where pastes realistically chain, like &lt;code&gt;A UNION B UNION C …&lt;/code&gt; thousands of arms long, were rewritten from recursion into flat lists: one &lt;code&gt;parseSelect&lt;/code&gt; frame and an array of set-operations, instead of one frame per arm. The stress suite now pastes 100 KB of garbage and 30,000-deep operator chains at every build, and the public package wraps the whole pipeline in a &lt;code&gt;lint()&lt;/code&gt; entry point that never throws, because the next editor to embed this will have the same no-error-sink problem we did.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test suite you can audit against AWS's docs
&lt;/h2&gt;

&lt;p&gt;The dialect rules — what DynamoDB accepts, what it rejects, which rewrite fixes what — all come from AWS's PartiQL reference. Documentation-derived behavior has a specific failure mode: the doc moves, the code doesn't, and nobody notices.&lt;/p&gt;

&lt;p&gt;So the corpus is structured against it. Two hundred and eight fixtures, and every one opens with the URL of the AWS documentation page the rule comes from. A coverage table maps each documented rule to its fixture, and the suite fails if a rule loses its fixture. When AWS changes the dialect, the diff is a fixture diff with a citation on it.&lt;/p&gt;

&lt;p&gt;That discipline paid for itself the week we open-sourced. The linter's &lt;code&gt;IN&lt;/code&gt;-list warning cited two caps: 50 values on a partition key column, 100 on a non-key column. Re-verifying every number before publication, we could confirm the 100 in AWS's current documentation — and could not find the 50 anywhere in an operative doc. It survives all over blog posts and old forum answers, but the primary source has moved on. The linter had it right by accident (it only warns past 100, since without your schema it can't tell which case applies), and the comment now says exactly which half of the claim is documented and which is folklore.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A hand-written recursive-descent parser for a small dialect is days of work, not months, and you own every error message. The scary version of "write a parser" assumes a big grammar.&lt;/li&gt;
&lt;li&gt;Build a CST, not an AST. Byte ranges on every node are what turn diagnostics into quick fixes; a lossy tree can't splice text.&lt;/li&gt;
&lt;li&gt;If the parser feeds a linter, error tolerance is the feature. Recover and continue; a parser that stops at the first error lints nothing after it.&lt;/li&gt;
&lt;li&gt;Swap engines behind a frozen interface with a parity corpus pinning old against new. The old implementation is the spec you already agreed to.&lt;/li&gt;
&lt;li&gt;Anywhere input can nest, someone will paste something that nests absurdly. Depth-cap the recursion and flatten the chains; test with garbage, not just queries.&lt;/li&gt;
&lt;li&gt;Cite your sources in the tests. A fixture that names the doc page it encodes is a test that can be audited when the doc changes — and it will change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The parser is &lt;a href="https://github.com/dynotable/dynamodb-partiql-parser" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt; and npm (&lt;code&gt;npm install dynamodb-partiql-parser&lt;/code&gt;), with the editor integration in &lt;a href="https://github.com/dynotable/codemirror-lang-partiql" rel="noopener noreferrer"&gt;codemirror-lang-partiql&lt;/a&gt;. If you want the dialect itself rather than the parser, &lt;a href="https://dynotable.com/learn/dynamodb-partiql-vs-sql" rel="noopener noreferrer"&gt;PartiQL vs SQL&lt;/a&gt; covers what DynamoDB's subset can and can't do and &lt;a href="https://dynotable.com/learn/dynamodb-partiql-examples" rel="noopener noreferrer"&gt;PartiQL examples&lt;/a&gt; is the practical walkthrough; the editor all of this was built for is &lt;a href="https://dynotable.com/docs/dynamodb-partiql" rel="noopener noreferrer"&gt;in DynoTable&lt;/a&gt;, and you can &lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;try it free&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Sparse Indexes</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:32:18 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-sparse-indexes-36k1</link>
      <guid>https://dev.to/dynotable/dynamodb-sparse-indexes-36k1</guid>
      <description>&lt;p&gt;A &lt;strong&gt;sparse index&lt;/strong&gt; is a secondary index that holds only the items carrying its&lt;br&gt;
key attribute — so a small, hot subset of a huge table becomes its own&lt;br&gt;
pre-filtered, ready-to-query collection.&lt;/p&gt;

&lt;p&gt;You have millions of rows but the query you run all day touches a tiny slice: the&lt;br&gt;
open support tickets, the unpaid invoices, the accounts flagged for review.&lt;/p&gt;

&lt;p&gt;Filtering that slice still scans the whole table and bills you for every read. A&lt;br&gt;
sparse index makes the index itself small instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a sparse index in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;A sparse index is a secondary index that holds only the items carrying its key attribute. Because DynamoDB skips any item missing that key, you invent a key only the wanted items write — open tickets, unpaid invoices — and the index becomes that exact subset. Queries then read just it, no filter, no wasted read capacity.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A secondary index only indexes items that have its key.&lt;/strong&gt; Omit the key on an
item and it never enters the index — no placeholder, no null row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;So you invent a key only the wanted items carry.&lt;/strong&gt; Write it on the items you
query, remove it on the rest. The index becomes exactly that subset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The query reads only the subset, no filter.&lt;/strong&gt; Its size tracks the small hot
set, not the table total.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;REMOVE&lt;/code&gt; is the lever, not blanking.&lt;/strong&gt; An empty string isn't a valid index
key — DynamoDB rejects the whole write with a ValidationException — so you must
delete the attribute.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: filtering doesn't save reads
&lt;/h2&gt;

&lt;p&gt;Coming from SQL, you assume a &lt;code&gt;WHERE&lt;/code&gt; clause narrows the work. DynamoDB's&lt;br&gt;
&lt;code&gt;FilterExpression&lt;/code&gt; does not. It runs &lt;strong&gt;after&lt;/strong&gt; items are read, not before.&lt;/p&gt;

&lt;p&gt;Per the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.FilterExpression.html" rel="noopener noreferrer"&gt;AWS Developer Guide&lt;/a&gt;,&lt;br&gt;
"a Query consumes the same amount of read capacity, regardless of whether a filter&lt;br&gt;
expression is present" — you pay for every item examined, then throw the&lt;br&gt;
non-matches away.&lt;/p&gt;

&lt;p&gt;So if 50 of your 5 million tickets are open, a filtered &lt;code&gt;Query&lt;/code&gt;/&lt;code&gt;Scan&lt;/code&gt; reads&lt;br&gt;
through millions to hand you those 50.&lt;/p&gt;

&lt;p&gt;That is the footgun behind every "why is my scan so expensive" thread;&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;query vs. scan&lt;/a&gt; has the full cost picture.&lt;/p&gt;

&lt;p&gt;A sparse index sidesteps it by making the index itself small.&lt;/p&gt;

&lt;h2&gt;
  
  
  How sparseness works
&lt;/h2&gt;

&lt;p&gt;A secondary index &lt;strong&gt;only indexes items that actually have the index's key&lt;br&gt;
attributes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-indexes-general-sparse-indexes.html" rel="noopener noreferrer"&gt;AWS docs on sparse indexes&lt;/a&gt;&lt;br&gt;
spell this out: DynamoDB writes an item to a secondary index only when that item&lt;br&gt;
carries the index's key attributes, so an index over a rarely-set attribute stays&lt;br&gt;
naturally small.&lt;/p&gt;

&lt;p&gt;Miss the GSI's partition key (or sort key) on an item and DynamoDB just doesn't&lt;br&gt;
write it to the index. No placeholder, no null row — the item is absent.&lt;/p&gt;

&lt;p&gt;That "absence by default" is the whole trick. Don't index a &lt;code&gt;status&lt;/code&gt; attribute&lt;br&gt;
that &lt;em&gt;every&lt;/em&gt; item carries. Invent an attribute that &lt;strong&gt;only the items you want to&lt;br&gt;
query carry at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The index then becomes a clean list of exactly those items, and a &lt;code&gt;Query&lt;/code&gt; against&lt;br&gt;
it reads only them — no filter, no wasted capacity.&lt;/p&gt;

&lt;p&gt;Picture the base table feeding the index, where only items carrying the key cross over:&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%2Fr7dy37z20zcjaffbarym.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%2Fr7dy37z20zcjaffbarym.png" alt="DynamoDB Sparse Indexes" width="800" height="2229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only the keyed (open) items replicate to the index; closed items never enter it.&lt;/p&gt;

&lt;p&gt;This is the same key-shaping mindset as&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;: keys are tools you build for a&lt;br&gt;
specific access pattern, not faithful mirrors of your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example: "open tickets only"
&lt;/h2&gt;

&lt;p&gt;Take a support-ticket table. The base table is keyed for fetching a ticket by id&lt;br&gt;
and listing a customer's tickets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PK&lt;/th&gt;
&lt;th&gt;SK&lt;/th&gt;
&lt;th&gt;attributes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TICKET#a91f&lt;/td&gt;
&lt;td&gt;DETAIL&lt;/td&gt;
&lt;td&gt;subject, body, priority, openState&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CUSTOMER#88&lt;/td&gt;
&lt;td&gt;TICKET#a91f&lt;/td&gt;
&lt;td&gt;subject, priority, openState&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Over the table's lifetime, most tickets end up &lt;strong&gt;closed&lt;/strong&gt;. But the dashboard query&lt;br&gt;
your agents hit all day is "show me every open ticket, oldest first" — a few&lt;br&gt;
hundred rows hiding inside millions.&lt;/p&gt;

&lt;p&gt;Define a &lt;a href="https://dynotable.com/docs/dynamodb-glossary#gsi" rel="noopener noreferrer"&gt;GSI&lt;/a&gt; with partition key &lt;code&gt;openBucket&lt;/code&gt; and sort key&lt;br&gt;
&lt;code&gt;openedAt&lt;/code&gt;, and &lt;strong&gt;only write &lt;code&gt;openBucket&lt;/code&gt; on open tickets&lt;/strong&gt;. Set it when the&lt;br&gt;
ticket is created; &lt;code&gt;REMOVE&lt;/code&gt; it when the ticket resolves.&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;openBucket&lt;/th&gt;
&lt;th&gt;openedAt&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TICKET#a91f&lt;/td&gt;
&lt;td&gt;DETAIL&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;2026-06-23T09:14:00Z&lt;/td&gt;
&lt;td&gt;← open: in the index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TICKET#b02c&lt;/td&gt;
&lt;td&gt;DETAIL&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;2026-06-22T16:40:00Z&lt;/td&gt;
&lt;td&gt;← open: in the index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TICKET#77de&lt;/td&gt;
&lt;td&gt;DETAIL&lt;/td&gt;
&lt;td&gt;(absent)&lt;/td&gt;
&lt;td&gt;2026-05-30T11:02:00Z&lt;/td&gt;
&lt;td&gt;← closed: NOT in the index&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tickets &lt;code&gt;a91f&lt;/code&gt; and &lt;code&gt;b02c&lt;/code&gt; carry &lt;code&gt;openBucket&lt;/code&gt;, so they live in the GSI. Ticket&lt;br&gt;
&lt;code&gt;77de&lt;/code&gt; was resolved and had &lt;code&gt;openBucket&lt;/code&gt; removed, so it silently dropped out. The&lt;br&gt;
dashboard is now one cheap query:&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  IndexName = "open-tickets-index"&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: openBucket = "OPEN"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ScanIndexForward: true        # oldest first&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;This reads only open tickets. As tickets close, the index shrinks on its own — its&lt;br&gt;
size tracks the &lt;em&gt;open&lt;/em&gt; population, never the total.&lt;/p&gt;

&lt;p&gt;One static partition value (&lt;code&gt;"OPEN"&lt;/code&gt;) is fine here precisely because the set stays&lt;br&gt;
small. A huge open set would need a sharded partition key, but the "small subset"&lt;br&gt;
index is exactly where one value is the right call.&lt;/p&gt;

&lt;p&gt;The transition that makes it work is a single &lt;a href="https://dynotable.com/docs/dynamodb-glossary#update-expression" rel="noopener noreferrer"&gt;update expression&lt;/a&gt; — removing the&lt;br&gt;
attribute when the ticket resolves.&lt;/p&gt;

&lt;p&gt;Prototype that &lt;code&gt;REMOVE&lt;/code&gt; clause and the typed key condition for the read side 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;, instead of&lt;br&gt;
hand-assembling &lt;code&gt;ExpressionAttributeNames&lt;/code&gt; and &lt;code&gt;:val&lt;/code&gt; placeholders yourself.&lt;/p&gt;

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

&lt;p&gt;The hard part of a sparse index is &lt;em&gt;seeing&lt;/em&gt; which items made it&lt;br&gt;
into the index versus which silently fell out.&lt;/p&gt;

&lt;p&gt;DynoTable lets you switch a table view to a secondary index and see exactly the&lt;br&gt;
populated subset. So you can confirm a resolved ticket really left&lt;br&gt;
&lt;code&gt;open-tickets-index&lt;/code&gt; instead of lingering with a stale 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%2Fmk82tfcmbeee2hjoteza.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%2Fmk82tfcmbeee2hjoteza.webp" alt="The support-ticket table viewed through its open-tickets sparse GSI in DynoTable, showing only the items that carry the openBucket key." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Remove the key, don't blank it.&lt;/strong&gt; An empty string isn't a valid index key —
writing &lt;code&gt;openBucket = ""&lt;/code&gt; fails with a ValidationException, so the item is never
indexed with it. To drop an item from the index you must &lt;code&gt;REMOVE&lt;/code&gt; the attribute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The index is &lt;a href="https://dynotable.com/docs/dynamodb-glossary#eventually-consistent" rel="noopener noreferrer"&gt;eventually consistent&lt;/a&gt;.&lt;/strong&gt; GSIs update asynchronously, so a
just-resolved ticket may briefly still appear — GSI reads
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html" rel="noopener noreferrer"&gt;support eventual consistency only&lt;/a&gt;.
Don't trust it for "is this ticket open right now".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind &lt;a href="https://dynotable.com/docs/dynamodb-glossary#projection" rel="noopener noreferrer"&gt;projected&lt;/a&gt; attributes.&lt;/strong&gt; A &lt;code&gt;Query&lt;/code&gt; on the index returns only the
attributes projected into it. If the dashboard needs subject and priority,
project them — or pay an extra &lt;code&gt;GetItem&lt;/code&gt; for the full base item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both GSIs and LSIs can be sparse&lt;/strong&gt; — the lever is the same: omit the index's
sort key on items you don't want indexed. A GSI is usually the better fit,
though: you can add it after table creation and give it its own key schema and
capacity. &lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi" rel="noopener noreferrer"&gt;GSI vs. LSI&lt;/a&gt; breaks down the trade-off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sparse indexes are one of the oldest ideas in the model. The original&lt;br&gt;
&lt;a href="https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf" rel="noopener noreferrer"&gt;2007 Amazon Dynamo paper&lt;/a&gt;&lt;br&gt;
built the store around serving known, high-volume access patterns cheaply.&lt;/p&gt;

&lt;p&gt;A sparse index is exactly that: shape the keys so the common query reads nothing it&lt;br&gt;
doesn't need.&lt;/p&gt;

&lt;p&gt;To build and inspect one for real, &lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt;, point it at&lt;br&gt;
your table, and flip the data view to your sparse GSI — watch the subset update as&lt;br&gt;
items gain and lose the index key.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Sort Key Strategies: 3 Patterns and When to Use Each</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:31:47 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-sort-key-strategies-3-patterns-and-when-to-use-each-31ih</link>
      <guid>https://dev.to/dynotable/dynamodb-sort-key-strategies-3-patterns-and-when-to-use-each-31ih</guid>
      <description>&lt;p&gt;A DynamoDB primary key is one or two attributes: a &lt;a href="https://dynotable.com/docs/dynamodb-glossary#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt; alone, or a&lt;br&gt;
partition key &lt;strong&gt;plus a sort key&lt;/strong&gt;. The partition key decides which physical&lt;br&gt;
partition holds an item.&lt;/p&gt;

&lt;p&gt;The sort key decides the &lt;strong&gt;order&lt;/strong&gt; of items inside that partition — and that&lt;br&gt;
ordering is what makes &lt;code&gt;Query&lt;/code&gt; powerful.&lt;/p&gt;

&lt;p&gt;Pick the wrong sort key and you can still write data, but you lose range reads,&lt;br&gt;
ordering, and several access patterns from one collection.&lt;/p&gt;

&lt;p&gt;Coming from SQL you'd reach for an &lt;code&gt;ORDER BY&lt;/code&gt; or a secondary index after the&lt;br&gt;
fact. In DynamoDB you bake the order into the key up front, or you don't get it.&lt;/p&gt;

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

&lt;p&gt;A DynamoDB sort key orders items within a partition, so &lt;code&gt;Query&lt;/code&gt; can do range reads — &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 fetching one item at a time. String sort keys order by UTF-8 bytes (Numbers order numerically), so design a string key (an ISO-8601 timestamp, a zero-padded number) so byte-order equals the order you want to read.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The sort key is your in-partition index.&lt;/strong&gt; It orders the &lt;a href="https://dynotable.com/docs/dynamodb-glossary#item-collection" rel="noopener noreferrer"&gt;item collection&lt;/a&gt; on
disk, so &lt;code&gt;Query&lt;/code&gt; can do range reads (&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
a single &lt;code&gt;GetItem&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;String sort keys order by UTF-8 bytes (Numbers order numerically).&lt;/strong&gt; Design a
string key so byte-order equals the order you want to read — an ISO-8601
timestamp, a zero-padded number, never a raw UUID or &lt;code&gt;6/23/2026&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One well-shaped sort key serves many access patterns.&lt;/strong&gt; A &lt;a href="https://dynotable.com/docs/dynamodb-glossary#composite-key" rel="noopener noreferrer"&gt;composite key&lt;/a&gt;
(&lt;code&gt;EVT#&amp;lt;timestamp&amp;gt;&lt;/code&gt;) is a prefix and a range at once — no GSI needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direction is free.&lt;/strong&gt; &lt;code&gt;ScanIndexForward = false&lt;/code&gt; reads newest-first at the same
cost; don't store reversed timestamps to fake it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the sort key is the lever
&lt;/h2&gt;

&lt;p&gt;Without a sort key, every item in a partition is addressable only by its full&lt;br&gt;
primary key — a &lt;code&gt;GetItem&lt;/code&gt; at best. Add a sort key and DynamoDB stores items&lt;br&gt;
&lt;strong&gt;sorted by it within the partition&lt;/strong&gt;, which unlocks &lt;code&gt;Query&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means range conditions (&lt;code&gt;&amp;gt;=&lt;/code&gt;, &lt;code&gt;between&lt;/code&gt;), prefix matching (&lt;code&gt;begins_with&lt;/code&gt;),&lt;br&gt;
and a &lt;code&gt;ScanIndexForward&lt;/code&gt; flag to read ascending or descending.&lt;/p&gt;

&lt;p&gt;Per the AWS DynamoDB Developer Guide, all items sharing a partition key form an&lt;br&gt;
&lt;strong&gt;item collection&lt;/strong&gt;, ordered on disk by the sort key.&lt;/p&gt;

&lt;p&gt;So the sort key isn't just a second identifier. It's the index you query against&lt;br&gt;
inside a partition.&lt;/p&gt;

&lt;p&gt;That ordering is byte-order on the encoded sort key: strings compare by UTF-8&lt;br&gt;
bytes, numbers compare numerically. This one fact drives almost every strategy&lt;br&gt;
below.&lt;/p&gt;

&lt;p&gt;If you want range queries to mean something, byte-order has to match the order you&lt;br&gt;
want to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strategy 1: make the sort key sortable
&lt;/h2&gt;

&lt;p&gt;The most common mistake is a sort key that isn't meaningfully ordered. A random&lt;br&gt;
UUID gives you uniqueness but no useful range query — "give me the last 20"&lt;br&gt;
becomes impossible because byte-order is arbitrary.&lt;/p&gt;

&lt;p&gt;Instead, encode the value you sort and filter on &lt;strong&gt;into&lt;/strong&gt; the sort key, in a&lt;br&gt;
representation whose byte-order equals its logical order. For timestamps that&lt;br&gt;
means a lexicographically-sortable format: an ISO-8601 string or a zero-padded&lt;br&gt;
epoch.&lt;/p&gt;

&lt;p&gt;ISO-8601 was designed so string comparison equals chronological comparison —&lt;br&gt;
exactly what a range query needs. Avoid formats like &lt;code&gt;6/23/2026&lt;/code&gt;; they sort wrong&lt;br&gt;
the moment the month rolls over.&lt;/p&gt;

&lt;p&gt;If you sort on numbers (a version counter, a score), use DynamoDB's native&lt;br&gt;
&lt;strong&gt;Number&lt;/strong&gt; type rather than a string, so &lt;code&gt;42&lt;/code&gt; sorts after &lt;code&gt;9&lt;/code&gt; instead of before&lt;br&gt;
it.&lt;/p&gt;

&lt;p&gt;If a number must live inside a composite string sort key, zero-pad it to a fixed&lt;br&gt;
width.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strategy 2: composite sort keys for hierarchy
&lt;/h2&gt;

&lt;p&gt;A sort key can encode a hierarchy by concatenating segments with a delimiter,&lt;br&gt;
most commonly &lt;code&gt;#&lt;/code&gt;. One &lt;code&gt;begins_with&lt;/code&gt; condition then selects a whole sub-tree:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EVENT#2026-06#01#login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EVENT#2026-06#03#export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EVENT#2026-07#02#login&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;begins_with(SK, "EVENT#2026-06#")&lt;/code&gt; returns just June's events; the broader&lt;br&gt;
&lt;code&gt;begins_with(SK, "EVENT#")&lt;/code&gt; returns all of them.&lt;/p&gt;

&lt;p&gt;Segment ordering is a design decision. Coarse-to-fine (year → month → day) keeps&lt;br&gt;
related items contiguous, so a range read stays one cheap query instead of a&lt;br&gt;
scatter across the partition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strategy 3: control direction with ScanIndexForward
&lt;/h2&gt;

&lt;p&gt;DynamoDB stores items in &lt;strong&gt;ascending&lt;/strong&gt; sort-key order and reads them that way by&lt;br&gt;
default. To read newest-first — the natural order for an activity feed — set&lt;br&gt;
&lt;code&gt;ScanIndexForward = false&lt;/code&gt; on the &lt;code&gt;Query&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a read-time flag, not a schema decision: the same collection serves both&lt;br&gt;
directions at the same cost. Don't invert your timestamps (storing a "reverse&lt;br&gt;
epoch") just to get descending reads.&lt;/p&gt;

&lt;p&gt;One item collection, stored once in ascending order, read either way:&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%2F2ozogcew8zf3sztjca6e.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%2F2ozogcew8zf3sztjca6e.png" alt="DynamoDB Sort Key Strategies: 3 Patterns and When to Use Each" width="800" height="1107"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same items, same partition, same cost — only the read direction differs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked example: an actor-scoped audit log
&lt;/h2&gt;

&lt;p&gt;Suppose you record timestamped events produced by actors — users, services, API&lt;br&gt;
keys — in a SaaS product, and you have two reads:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The activity stream for one actor&lt;/strong&gt;, newest event first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One actor's events within a time window&lt;/strong&gt; (e.g. "everything between the two
deploys"), for an investigation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both reads are scoped to a single actor, so the actor is the partition key and the&lt;br&gt;
event time is the sort key. Use generic key names so the same table can hold other&lt;br&gt;
entities later:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PK&lt;/th&gt;
&lt;th&gt;SK&lt;/th&gt;
&lt;th&gt;attributes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ACTOR#u_8814&lt;/td&gt;
&lt;td&gt;EVT#2026-06-23T09:12:04Z&lt;/td&gt;
&lt;td&gt;action=login, ip, ua&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ACTOR#u_8814&lt;/td&gt;
&lt;td&gt;EVT#2026-06-23T14:05:11Z&lt;/td&gt;
&lt;td&gt;action=export, target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ACTOR#u_8814&lt;/td&gt;
&lt;td&gt;EVT#2026-06-24T08:40:55Z&lt;/td&gt;
&lt;td&gt;action=login, ip, ua&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ACTOR#svc_billing&lt;/td&gt;
&lt;td&gt;EVT#2026-06-23T00:00:00Z&lt;/td&gt;
&lt;td&gt;action=invoice.run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;EVT#&lt;/code&gt; prefix plus an ISO-8601 timestamp gives a sortable sort key. Read 1 is&lt;br&gt;
&lt;code&gt;Query PK = "ACTOR#u_8814"&lt;/code&gt; with &lt;code&gt;ScanIndexForward = false&lt;/code&gt; for newest-first. Read&lt;br&gt;
2 narrows the same partition with a &lt;code&gt;between&lt;/code&gt; condition on 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;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;PK = "ACTOR#u_8814"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AND SK BETWEEN "EVT#2026-06-23T00:00:00Z"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AND "EVT#2026-06-23T23:59:59Z"&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;One collection, two access patterns, no GSI — because the sort key is both a prefix&lt;br&gt;
(&lt;code&gt;EVT#&lt;/code&gt;) and a range (the timestamp). The descending read and the window read are&lt;br&gt;
the &lt;strong&gt;same&lt;/strong&gt; items in the same order; only the parameters differ.&lt;/p&gt;

&lt;p&gt;Building that key condition by hand, it's easy to fumble the &lt;code&gt;between&lt;/code&gt; bounds or&lt;br&gt;
the reserved-word escaping on attribute names.&lt;/p&gt;

&lt;p&gt;The &lt;a href="/tools/dynamodb-expression-builder"&gt;DynamoDB Expression Builder&lt;/a&gt;&lt;br&gt;
generates the &lt;code&gt;KeyConditionExpression&lt;/code&gt;, the &lt;code&gt;ExpressionAttributeNames&lt;/code&gt;, and the&lt;br&gt;
&lt;code&gt;ExpressionAttributeValues&lt;/code&gt; for a &lt;code&gt;begins_with&lt;/code&gt; or &lt;code&gt;between&lt;/code&gt; sort-key condition.&lt;/p&gt;

&lt;p&gt;Copy it straight into your SDK call instead of debugging escaping at runtime.&lt;/p&gt;

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

&lt;p&gt;Designing a sort key is iterative: write a few representative items, run the range&lt;br&gt;
query, and check the rows come back in the order you expect. Doing that against a&lt;br&gt;
live table in a GUI beats round-tripping through code.&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%2F42fcp97y05cqfx7olkt5.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%2F42fcp97y05cqfx7olkt5.webp" alt="Querying an actor's audit-log collection in DynoTable with a between condition on the sort key, results ordered newest-first." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Flip the sort direction, tighten the &lt;code&gt;between&lt;/code&gt; bounds, and watch the returned&lt;br&gt;
collection change without writing a line of code — the fastest way to confirm a&lt;br&gt;
sort-key design before you commit it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sort keys must be unique within a partition.&lt;/strong&gt; If two events can share a
timestamp, append a disambiguator (a sequence number or short id) to the sort
key so the composite stays unique.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hot partition can't be sorted around.&lt;/strong&gt; If one actor produces far more
events than the rest, the sort key won't save you — you need a partition-key
design that spreads the load. See
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second sort order needs a second index.&lt;/strong&gt; The base table's sort key gives
one ordering. To order the same items differently (by event type, say), add a
GSI with a different sort key — weighing the
&lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi" rel="noopener noreferrer"&gt;local vs global secondary index&lt;/a&gt; trade-offs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't reach for &lt;code&gt;Scan&lt;/code&gt; to "sort later".&lt;/strong&gt; Sorting client-side after a &lt;code&gt;Scan&lt;/code&gt;
reads the whole table and throws ordering away; that's the
&lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;Scan footgun&lt;/a&gt;. Push the order into the sort key instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once the key condition is right, &lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;try DynoTable&lt;/a&gt; to model the&lt;br&gt;
collection, run the ascending and descending queries side by side, and verify your&lt;br&gt;
sort-key strategy against real data before it ships.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>PartiQL for DynamoDB by Example</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:31:46 +0000</pubDate>
      <link>https://dev.to/dynotable/partiql-for-dynamodb-by-example-g39</link>
      <guid>https://dev.to/dynotable/partiql-for-dynamodb-by-example-g39</guid>
      <description>&lt;p&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary#partiql" rel="noopener noreferrer"&gt;PartiQL&lt;/a&gt; is a SQL-compatible query language for DynamoDB. It's friendlier than the&lt;br&gt;
raw API for ad-hoc work — but it runs on the same engine, so the same key rules&lt;br&gt;
(and the same costs) apply underneath the familiar syntax.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do you write PartiQL queries for DynamoDB?
&lt;/h2&gt;

&lt;p&gt;PartiQL gives DynamoDB four SQL-shaped statements — &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;,&lt;br&gt;
and &lt;code&gt;DELETE&lt;/code&gt; — run through &lt;code&gt;ExecuteStatement&lt;/code&gt;. Each one compiles to a native&lt;br&gt;
operation underneath, so filtering on the &lt;a href="https://dynotable.com/docs/dynamodb-glossary#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt; stays a&lt;br&gt;
&lt;code&gt;Query&lt;/code&gt; while omitting it becomes a full-table &lt;code&gt;Scan&lt;/code&gt;. Writes still target one item&lt;br&gt;
by its &lt;a href="https://dynotable.com/docs/dynamodb-glossary#primary-key" rel="noopener noreferrer"&gt;primary key&lt;/a&gt;; there's no relational &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, or aggregate.&lt;/p&gt;

&lt;p&gt;Every statement runs through the same &lt;code&gt;ExecuteStatement&lt;/code&gt; API (or its batch/transaction&lt;br&gt;
variants). Table and index names must be double-quoted when they contain special&lt;br&gt;
characters; attribute names in expressions follow the same quoting rules as the&lt;br&gt;
low-level API. The &lt;a href="https://dynotable.com/docs/dynamodb-partiql" rel="noopener noreferrer"&gt;PartiQL docs in DynoTable&lt;/a&gt; mirror these&lt;br&gt;
examples with schema-aware autocomplete and a searchable history of past runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  SELECT
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"AppData"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"PK"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'CUSTOMER#42'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;begins_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"SK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ORDER#'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Filter on the &lt;a href="https://dynotable.com/docs/dynamodb-glossary#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt; and this is a &lt;code&gt;Query&lt;/code&gt;. &lt;strong&gt;Omit&lt;/strong&gt; the partition key and&lt;br&gt;
PartiQL silently runs a full-table &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;Scan&lt;/a&gt; — same footgun,&lt;br&gt;
just hidden behind &lt;code&gt;SELECT *&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;
&lt;code&gt;WHERE&lt;/code&gt; clause shape&lt;/th&gt;
&lt;th&gt;Compiled operation&lt;/th&gt;
&lt;th&gt;Typical cost driver&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PK = ?&lt;/code&gt; (exact partition)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Items in that partition (plus filter waste)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PK = ? AND begins_with(SK, ?)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sort-key range within one partition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GSI1PK = ?&lt;/code&gt; via &lt;code&gt;FROM "Table"."GSI1"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Query&lt;/code&gt; on index&lt;/td&gt;
&lt;td&gt;Items in the index partition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No partition-key equality on table or index&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Every&lt;/strong&gt; item in the target, before filters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Put numbers on the Scan path: a table with 500,000 items averaging 1 KB each meters&lt;br&gt;
~125,000 read request units on on-demand billing for a full &lt;code&gt;SELECT *&lt;/code&gt; (500,000 KB&lt;br&gt;
÷ 4 KB per unit, eventually consistent at 0.5 RCU each). Add &lt;code&gt;WHERE status = 'OPEN'&lt;/code&gt;&lt;br&gt;
and the bill is identical — filtering happens after the read is metered. The keyed&lt;br&gt;
&lt;code&gt;SELECT&lt;/code&gt; in the example above touches only the &lt;code&gt;CUSTOMER#42&lt;/code&gt; partition; if that&lt;br&gt;
collection holds 40 orders at 1 KB each, the same eventually-consistent read costs&lt;br&gt;
&lt;strong&gt;5&lt;/strong&gt; units (40 KB total), not 125,000.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ExecuteStatement&lt;/code&gt; paginates like the native API: a non-empty &lt;code&gt;NextToken&lt;/code&gt; means&lt;br&gt;
more rows remain. Loop until the token is absent (&lt;a href="https://dynotable.com/learn/dynamodb-pagination" rel="noopener noreferrer"&gt;pagination guide&lt;/a&gt;).&lt;/p&gt;
&lt;h2&gt;
  
  
  INSERT
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="nv"&gt;"AppData"&lt;/span&gt; &lt;span class="n"&gt;VALUE&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s1"&gt;'PK'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'CUSTOMER#42'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SK'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'PROFILE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'plan'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  UPDATE
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="nv"&gt;"AppData"&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="nv"&gt;"plan"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'enterprise'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"PK"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'CUSTOMER#42'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"SK"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PROFILE'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  DELETE
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"AppData"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"PK"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'CUSTOMER#42'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"SK"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ORDER#2026-001'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Querying an index
&lt;/h2&gt;

&lt;p&gt;Use the index name in the &lt;code&gt;FROM&lt;/code&gt; clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"AppData"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"GSI1"&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"GSI1PK"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'STATUS#OPEN'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WHERE&lt;/code&gt; also supports &lt;code&gt;IN&lt;/code&gt;, &lt;code&gt;contains()&lt;/code&gt; and &lt;code&gt;begins_with()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"AppData"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"PK"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'CUSTOMER#42'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"SK"&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ORDER#1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ORDER#2'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Parameterized statements
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;?&lt;/code&gt; placeholders instead of inlining values — it sidesteps quoting/injection&lt;br&gt;
issues and lets the SDK marshal types for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"AppData"&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"PK"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;begins_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"SK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass &lt;code&gt;Parameters: [{ S: 'CUSTOMER#42' }, { S: 'ORDER#' }]&lt;/code&gt; to &lt;code&gt;ExecuteStatement&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Prefer parameters in application code and reserve string interpolation for ad-hoc&lt;br&gt;
console sessions. The SDK marshals types (&lt;code&gt;N&lt;/code&gt; for numbers, &lt;code&gt;BOOL&lt;/code&gt; for booleans)&lt;br&gt;
so you avoid the classic &lt;code&gt;"42"&lt;/code&gt; string where DynamoDB expected a number attribute.&lt;br&gt;
Reserved attribute names still need aliases in PartiQL exactly as in&lt;br&gt;
&lt;code&gt;FilterExpression&lt;/code&gt; — run your names through the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-reserved-words-checker" rel="noopener noreferrer"&gt;reserved-words checker&lt;/a&gt; before baking them&lt;br&gt;
into a stored statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch and transactions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BatchExecuteStatement&lt;/code&gt;&lt;/strong&gt; — up to &lt;strong&gt;25&lt;/strong&gt; statements in one round trip. Faster,
but no cross-item atomicity (each succeeds or fails on its own).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ExecuteTransaction&lt;/code&gt;&lt;/strong&gt; — up to &lt;strong&gt;100&lt;/strong&gt; statements, &lt;strong&gt;all-or-nothing&lt;/strong&gt;. Use it
when several writes must commit together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Batch and transaction ceilings are fixed service limits, not tuning knobs. A cart&lt;br&gt;
checkout that touches 30 line items still needs chunking: two &lt;code&gt;BatchExecuteStatement&lt;/code&gt;&lt;br&gt;
calls (25 + 5) or one transaction if you need atomicity across all 30 (and each&lt;br&gt;
statement still targets a single item by full primary key).&lt;/p&gt;

&lt;h2&gt;
  
  
  PartiQL vs Workbench SQL
&lt;/h2&gt;

&lt;p&gt;PartiQL executes &lt;strong&gt;on DynamoDB&lt;/strong&gt; — one keyed access pattern per statement. DynoTable's&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-sql-workbench" rel="noopener noreferrer"&gt;SQL Workbench&lt;/a&gt; executes &lt;strong&gt;in the client&lt;/strong&gt; over rows&lt;br&gt;
you've already pulled, which unlocks &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, and aggregates PartiQL&lt;br&gt;
deliberately omits. The split is operational, not cosmetic:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single-partition read/write by primary key&lt;/td&gt;
&lt;td&gt;PartiQL &lt;code&gt;SELECT&lt;/code&gt; / DML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-partition filter you modeled a GSI for&lt;/td&gt;
&lt;td&gt;PartiQL against the index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ad-hoc join across two entity types&lt;/td&gt;
&lt;td&gt;Workbench SQL over bounded queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly revenue roll-up by SKU&lt;/td&gt;
&lt;td&gt;Workbench &lt;code&gt;GROUP BY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production hot-path microservice&lt;/td&gt;
&lt;td&gt;Native &lt;code&gt;Query&lt;/code&gt; / &lt;code&gt;GetItem&lt;/code&gt; APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Workbench still respects DynamoDB's access rules: you fetch partitions with keyed&lt;br&gt;
reads (or bounded Scans you accept consciously), then SQL shapes the result set&lt;br&gt;
locally. See &lt;a href="https://dynotable.com/learn/dynamodb-partiql-vs-sql" rel="noopener noreferrer"&gt;PartiQL vs SQL&lt;/a&gt; for the full comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PartiQL footgun
&lt;/h2&gt;

&lt;p&gt;PartiQL &lt;em&gt;looks&lt;/em&gt; like SQL but runs on the DynamoDB engine, so SQL habits backfire:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A single &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; must target &lt;strong&gt;one&lt;/strong&gt; item by its full &lt;a href="https://dynotable.com/docs/dynamodb-glossary#primary-key" rel="noopener noreferrer"&gt;primary key&lt;/a&gt; —
there's no &lt;code&gt;UPDATE … WHERE status = 'x'&lt;/code&gt; mass update (loop with a batch instead).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;JOIN&lt;/code&gt;, no &lt;code&gt;GROUP BY&lt;/code&gt;, no aggregates&lt;/strong&gt; (&lt;code&gt;COUNT&lt;/code&gt;/&lt;code&gt;SUM&lt;/code&gt;/&lt;code&gt;AVG&lt;/code&gt;). See
&lt;a href="https://dynotable.com/learn/dynamodb-partiql-vs-sql" rel="noopener noreferrer"&gt;PartiQL vs SQL&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Omitting the partition key turns any &lt;code&gt;SELECT&lt;/code&gt; into a full-table
&lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;Scan&lt;/a&gt; — bounded only by your bill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you genuinely need a &lt;code&gt;JOIN&lt;/code&gt;, a &lt;code&gt;GROUP BY&lt;/code&gt;, or an aggregate, DynoTable's SQL&lt;br&gt;
Workbench runs them client-side over the rows you've pulled — the SQL PartiQL&lt;br&gt;
can't speak, inside DynamoDB's access-pattern rules.&lt;/p&gt;

&lt;p&gt;PartiQL doesn't change the underlying &lt;a href="https://dynotable.com/learn/dynamodb-data-types" rel="noopener noreferrer"&gt;data types&lt;/a&gt; — values&lt;br&gt;
still go over the wire as DynamoDB-JSON, which you can inspect with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-json-converter" rel="noopener noreferrer"&gt;converter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For UPDATE and DELETE statements, the &lt;code&gt;WHERE&lt;/code&gt; clause must resolve to &lt;strong&gt;exactly one&lt;/strong&gt;&lt;br&gt;
item. Partial-key &lt;code&gt;WHERE&lt;/code&gt; clauses that match many rows fail at compile time or runtime&lt;br&gt;
rather than mass-updating — loop with a keyed &lt;code&gt;Query&lt;/code&gt;, then issue per-item DML inside&lt;br&gt;
&lt;code&gt;BatchExecuteStatement&lt;/code&gt; or a transaction when you need atomicity.&lt;/p&gt;

&lt;p&gt;Complex &lt;code&gt;KeyConditionExpression&lt;/code&gt; shapes (nested &lt;code&gt;begins_with&lt;/code&gt;, mixed &lt;code&gt;AND&lt;/code&gt;/&lt;code&gt;OR&lt;/code&gt;) are&lt;br&gt;
easier to prototype 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; and translate into&lt;br&gt;
PartiQL's &lt;code&gt;WHERE&lt;/code&gt; syntax once the condition is correct.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;Try DynoTable&lt;/a&gt; to run PartiQL statements with schema-aware autocomplete&lt;br&gt;
and browse the results in a sortable table view — then pivot the same dataset into&lt;br&gt;
Workbench when you need a join or aggregate the engine cannot push down.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Pagination: LastEvaluatedKey Explained (w/ Examples)</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:37:56 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-pagination-lastevaluatedkey-explained-w-examples-31n7</link>
      <guid>https://dev.to/dynotable/dynamodb-pagination-lastevaluatedkey-explained-w-examples-31n7</guid>
      <description>&lt;p&gt;DynamoDB never returns "all" results in one call. A &lt;code&gt;Query&lt;/code&gt; or &lt;code&gt;Scan&lt;/code&gt; returns at&lt;br&gt;
most 1 MB of data, then hands you a &lt;code&gt;LastEvaluatedKey&lt;/code&gt; to resume from. Getting&lt;br&gt;
pagination right means looping on that key — not on a counter.&lt;/p&gt;
&lt;h2&gt;
  
  
  How does pagination work in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Query&lt;/code&gt; or &lt;code&gt;Scan&lt;/code&gt; returns at most 1 MB per call, then hands back a &lt;code&gt;LastEvaluatedKey&lt;/code&gt;. To page, you pass that key as the next call's &lt;code&gt;ExclusiveStartKey&lt;/code&gt; and loop until DynamoDB returns no key. There are no page numbers, no total count, and &lt;code&gt;Limit&lt;/code&gt; caps items evaluated — not items returned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;key&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;out&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;QueryCommand&lt;/span&gt;&lt;span class="p"&gt;({...&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ExclusiveStartKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LastEvaluatedKey&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;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;LastEvaluatedKey&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, you've reached the end. Pass it back as&lt;br&gt;
&lt;code&gt;ExclusiveStartKey&lt;/code&gt; to fetch the next slice.&lt;/p&gt;

&lt;p&gt;Each page is bounded by &lt;strong&gt;two&lt;/strong&gt; independent ceilings: the &lt;code&gt;Limit&lt;/code&gt; you set (if&lt;br&gt;
any) and a hard &lt;strong&gt;1 MB&lt;/strong&gt; response cap. A partition with chunky items can fill a&lt;br&gt;
page on three rows even when &lt;code&gt;Limit&lt;/code&gt; is 100 — DynamoDB stops when either bound&lt;br&gt;
is hit and still returns a &lt;code&gt;LastEvaluatedKey&lt;/code&gt; if more data remains. Plan UI copy&lt;br&gt;
around "load more" rather than "showing 25 of N", because N is unknown until&lt;br&gt;
you've walked every page.&lt;/p&gt;

&lt;p&gt;The control flow is a single loop that exits only on an absent 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%2Fk5vo9x5mpkbnabodk9m2.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%2Fk5vo9x5mpkbnabodk9m2.png" alt="DynamoDB Pagination: LastEvaluatedKey Explained (w/ Examples)" width="800" height="1044"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every pass either resumes from the returned key or stops — there is no counter.&lt;/p&gt;
&lt;h2&gt;
  
  
  Limit is not a page size
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Limit&lt;/code&gt; caps how many items DynamoDB &lt;strong&gt;evaluates&lt;/strong&gt;, not how many it returns after&lt;br&gt;
a &lt;code&gt;FilterExpression&lt;/code&gt;. A &lt;code&gt;Limit: 25&lt;/code&gt; query behind a filter can return 3 items and&lt;br&gt;
still hand you a &lt;code&gt;LastEvaluatedKey&lt;/code&gt; — you must keep paging until the key is empty,&lt;br&gt;
even when a page looks short. A &lt;strong&gt;non-empty&lt;/strong&gt; &lt;code&gt;LastEvaluatedKey&lt;/code&gt; never promises&lt;br&gt;
more &lt;em&gt;matching&lt;/em&gt; items either; only an &lt;strong&gt;absent&lt;/strong&gt; key proves you've reached the end.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you might expect&lt;/th&gt;
&lt;th&gt;What DynamoDB actually does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Limit: 25&lt;/code&gt; → 25 rows in the page&lt;/td&gt;
&lt;td&gt;Evaluates up to 25 items; filters may shrink the returned set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short page → end of data&lt;/td&gt;
&lt;td&gt;Short page + non-empty key → keep paging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Empty page → done&lt;/td&gt;
&lt;td&gt;Empty page + non-empty key → more data exists beyond the filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Limit&lt;/code&gt; controls bill per request&lt;/td&gt;
&lt;td&gt;Bill follows items &lt;strong&gt;read&lt;/strong&gt;, including filtered-out rows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A concrete read: partition &lt;code&gt;USER#42&lt;/code&gt; holds 200 order items averaging 2 KB each.&lt;br&gt;
&lt;code&gt;Query&lt;/code&gt; with &lt;code&gt;Limit: 50&lt;/code&gt; and &lt;code&gt;FilterExpression: status = 'OPEN'&lt;/code&gt; might evaluate&lt;br&gt;
50 items (~100 KB metered), match 4, and return a key — you page again. Without&lt;br&gt;
the filter, the same &lt;code&gt;Limit: 50&lt;/code&gt; evaluates 50 items and bills ~25 read capacity&lt;br&gt;
units on-demand (50 × 2 KB → 100 KB, rounded up per 4 KB block at 0.5 RCU each&lt;br&gt;
for eventually-consistent reads). Pass &lt;code&gt;ReturnConsumedCapacity: TOTAL&lt;/code&gt; on every&lt;br&gt;
call to see the metered units per page instead of guessing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Let the SDK paginate
&lt;/h2&gt;

&lt;p&gt;Both SDKs wrap the loop above so you can iterate pages directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AWS SDK for JavaScript v3&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;paginateQuery&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/lib-dynamodb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;page&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;paginateQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# boto3
&lt;/span&gt;&lt;span class="n"&gt;paginator&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;get_paginator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;paginator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;paginate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&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;
  
  
  No page numbers
&lt;/h2&gt;

&lt;p&gt;DynamoDB has &lt;strong&gt;no total count and no random page access&lt;/strong&gt; — you can't jump to&lt;br&gt;
"page 7" or page backwards without replaying the cursors. Design UIs around&lt;br&gt;
infinite scroll / "load more", not numbered pages. (A &lt;code&gt;Select: 'COUNT'&lt;/code&gt; query&lt;br&gt;
still reads — and bills for — every matched item to count them.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Stateless cursors for APIs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LastEvaluatedKey&lt;/code&gt; is just the key attributes of the last item. Base64-encode it&lt;br&gt;
and hand it to clients as an opaque &lt;code&gt;nextToken&lt;/code&gt;; decode it back into&lt;br&gt;
&lt;code&gt;ExclusiveStartKey&lt;/code&gt; on the next request. No server-side cursor state.&lt;/p&gt;

&lt;p&gt;That token is DynamoDB-JSON — eyeball or hand-craft one with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-json-converter" rel="noopener noreferrer"&gt;DynamoDB-JSON converter&lt;/a&gt;. And if you're paging to&lt;br&gt;
work around a &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;Scan&lt;/a&gt;, that's usually a signal to add an&lt;br&gt;
index instead.&lt;/p&gt;

&lt;p&gt;Treat the token as &lt;strong&gt;opaque and immutable&lt;/strong&gt;. Clients must send back exactly what&lt;br&gt;
you issued; decoding, mutating a sort-key component, and re-encoding breaks the&lt;br&gt;
resume point and can skip or duplicate rows. Version the envelope (&lt;code&gt;{"v":1,"lek":…}&lt;/code&gt;)&lt;br&gt;
so you can rotate encoding without breaking in-flight sessions. For &lt;code&gt;Scan&lt;/code&gt; pages,&lt;br&gt;
the key includes the segment id when you use parallel segments — a token from&lt;br&gt;
segment 2 must resume segment 2, not segment 0.&lt;/p&gt;

&lt;p&gt;PartiQL's &lt;code&gt;ExecuteStatement&lt;/code&gt; uses the same resume model under a different name:&lt;br&gt;
&lt;code&gt;NextToken&lt;/code&gt; on the response becomes &lt;code&gt;NextToken&lt;/code&gt; on the next request. The mental&lt;br&gt;
model — loop until the token is absent — is identical to &lt;code&gt;Query&lt;/code&gt;/&lt;code&gt;Scan&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick a pagination strategy
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manual &lt;code&gt;do/while&lt;/code&gt; on the key&lt;/td&gt;
&lt;td&gt;Full control, custom backoff, mixed ops&lt;/td&gt;
&lt;td&gt;Easy to forget error handling or capacity caps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SDK paginator (&lt;code&gt;paginateQuery&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Batch jobs, exports, CLI tools&lt;/td&gt;
&lt;td&gt;Less control over per-page side effects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base64 &lt;code&gt;nextToken&lt;/code&gt; in your API&lt;/td&gt;
&lt;td&gt;Mobile/web "load more"&lt;/td&gt;
&lt;td&gt;Must validate and never expose raw table keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynoTable result grid&lt;/td&gt;
&lt;td&gt;Exploratory reads, verifying key order&lt;/td&gt;
&lt;td&gt;Client-side; not a server pagination pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Whichever path you choose, &lt;strong&gt;never&lt;/strong&gt; infer progress from page index. Page 14 of a&lt;br&gt;
&lt;code&gt;Scan&lt;/code&gt; over a growing table is not "14 × Limit items in" — items added or deleted&lt;br&gt;
between calls can shift boundaries. Idempotent downstream writes (natural keys,&lt;br&gt;
conditional puts) keep replays safe when a client retries the same token after a&lt;br&gt;
timeout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capacity adds up across pages
&lt;/h2&gt;

&lt;p&gt;Pagination does not discount reads. Ten pages that each touch 1 MB of item data&lt;br&gt;
meter roughly ten times the single-page cost. Background jobs that walk an entire&lt;br&gt;
table via &lt;code&gt;Query&lt;/code&gt; on a GSI should multiply "cost per page" by "pages until key&lt;br&gt;
absent" before scheduling — the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt; accepts that per-page&lt;br&gt;
unit count directly.&lt;/p&gt;

&lt;p&gt;Large responses also hit wire limits before capacity limits: if a single item&lt;br&gt;
approaches 400 KB, you may get one item per page regardless of &lt;code&gt;Limit&lt;/code&gt;. The&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator" rel="noopener noreferrer"&gt;item size calculator&lt;/a&gt; shows when an item&lt;br&gt;
crosses the 4 KB read rounding boundary (one RCU per 4 KB for strongly-consistent&lt;br&gt;
reads, half that eventually consistent).&lt;/p&gt;

&lt;h2&gt;
  
  
  Build and inspect the loop
&lt;/h2&gt;

&lt;p&gt;To skip writing the loop at all, the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-query-builder" rel="noopener noreferrer"&gt;query builder&lt;/a&gt; composes the full Query/Scan&lt;br&gt;
request and emits a runnable SDK v3, CLI, or boto3 program — pagination loop&lt;br&gt;
included. Set your partition key, optional sort condition, projection, and filter;&lt;br&gt;
the emitted program wraps &lt;code&gt;paginateQuery&lt;/code&gt; or an equivalent manual loop with&lt;br&gt;
&lt;code&gt;ExclusiveStartKey&lt;/code&gt; wiring already in place.&lt;/p&gt;

&lt;p&gt;For the underlying API fields and consistency options, see&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-querying" rel="noopener noreferrer"&gt;Querying in DynoTable&lt;/a&gt; — the same pagination rules apply&lt;br&gt;
whether you call the SDK, PartiQL, or the desktop app's PartiQL tab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;Try DynoTable&lt;/a&gt; to page through query results visually, with the cursor&lt;br&gt;
tracked for you and &lt;code&gt;ReturnConsumedCapacity&lt;/code&gt; surfaced per request so you can see&lt;br&gt;
each page's read units without wrapping every call yourself.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>How to Run DynamoDB Local with Docker — Complete Guide</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:37:20 +0000</pubDate>
      <link>https://dev.to/dynotable/how-to-run-dynamodb-local-with-docker-complete-guide-3il4</link>
      <guid>https://dev.to/dynotable/how-to-run-dynamodb-local-with-docker-complete-guide-3il4</guid>
      <description>&lt;p&gt;DynamoDB Local is AWS's downloadable emulation of DynamoDB in a single process —&lt;br&gt;
same API, no AWS account, no network, no per-request bill. Use it for local&lt;br&gt;
development and integration tests, then point the same code at the cloud in&lt;br&gt;
production. It ignores provisioned throughput and never throttles, so it can't&lt;br&gt;
stand in for load or limit testing.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do I run DynamoDB Local with Docker?
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;docker run -p 8000:8000 amazon/dynamodb-local&lt;/code&gt; to start the official image,&lt;br&gt;
which exposes the DynamoDB engine on &lt;code&gt;http://localhost:8000&lt;/code&gt;. Point your AWS SDK&lt;br&gt;
or CLI at that endpoint with any dummy credentials, then create tables and run&lt;br&gt;
requests exactly as you would against the cloud. Add &lt;code&gt;-sharedDb&lt;/code&gt; and a mounted&lt;br&gt;
&lt;code&gt;-dbPath&lt;/code&gt; volume to keep data across restarts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Start the container
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8000:8000 amazon/dynamodb-local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That exposes the engine on &lt;code&gt;http://localhost:8000&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  docker-compose
&lt;/h2&gt;

&lt;p&gt;Most projects pin it in &lt;code&gt;docker-compose.yml&lt;/code&gt; so the whole team gets the same&lt;br&gt;
endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dynamodb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;amazon/dynamodb-local&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-jar&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;DynamoDBLocal.jar&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-sharedDb&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-dbPath&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/data'&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8000:8000'&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dynamodb-data:/data&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dynamodb-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image runs as the non-root &lt;code&gt;dynamodblocal&lt;/code&gt; user, which can't open a&lt;br&gt;
database file inside the root-owned named volume — without &lt;code&gt;user: root&lt;/code&gt; you hit&lt;br&gt;
&lt;code&gt;SQLiteException [14] unable to open database file&lt;/code&gt; and every call hangs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Persistence
&lt;/h2&gt;

&lt;p&gt;By default DynamoDB Local is &lt;strong&gt;in-memory&lt;/strong&gt; — every table vanishes when the&lt;br&gt;
container stops. Two flags make it durable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-sharedDb&lt;/code&gt;&lt;/strong&gt; keeps all clients on one shared database file (without it, each
set of credentials/region gets its own isolated DB — a common "where did my
table go?" surprise).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-dbPath /data&lt;/code&gt;&lt;/strong&gt; + a mounted volume writes that file to disk, so data
survives &lt;code&gt;docker compose down&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Point the SDK at it
&lt;/h2&gt;

&lt;p&gt;Only the endpoint changes — credentials can be any dummy values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;DynamoDBClient&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="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;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:8000&lt;/span&gt;&lt;span class="dl"&gt;'&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;local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Create a table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws dynamodb create-table &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; http://localhost:8000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; AppData &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attribute-definitions&lt;/span&gt; &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PK,AttributeType&lt;span class="o"&gt;=&lt;/span&gt;S &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SK,AttributeType&lt;span class="o"&gt;=&lt;/span&gt;S &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key-schema&lt;/span&gt; &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PK,KeyType&lt;span class="o"&gt;=&lt;/span&gt;HASH &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SK,KeyType&lt;span class="o"&gt;=&lt;/span&gt;RANGE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--billing-mode&lt;/span&gt; PAY_PER_REQUEST
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;a href="https://dynotable.com/learn/dynamodb-single-table-design" rel="noopener noreferrer"&gt;single-table&lt;/a&gt; &lt;code&gt;PK&lt;/code&gt;/&lt;code&gt;SK&lt;/code&gt; schema like this is a good&lt;br&gt;
default. When you load fixtures, convert plain JSON to the wire format with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-json-converter" rel="noopener noreferrer"&gt;DynamoDB-JSON converter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Verify the container is up and the table landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws dynamodb list-tables &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; http://localhost:8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Browse it with a GUI
&lt;/h2&gt;

&lt;p&gt;CLI calls get tedious fast. The usual options are the open-source &lt;code&gt;dynamodb-admin&lt;/code&gt;&lt;br&gt;
web UI or a desktop client. &lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;DynoTable&lt;/a&gt; connects straight to&lt;br&gt;
&lt;code&gt;localhost:8000&lt;/code&gt; (or any LocalStack endpoint — see&lt;br&gt;
&lt;a href="https://dynotable.com/learn/connect-dynamodb-local-localstack" rel="noopener noreferrer"&gt;connecting to DynamoDB Local &amp;amp; LocalStack&lt;/a&gt;)&lt;br&gt;
and lets you browse, query with the &lt;a href="https://dynotable.com/docs/dynamodb-glossary#workbench" rel="noopener noreferrer"&gt;SQL Workbench&lt;/a&gt;, and edit local tables with the&lt;br&gt;
same UI you use for cloud tables — no &lt;code&gt;aws&lt;/code&gt; CLI round-trips.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Local does not emulate
&lt;/h2&gt;

&lt;p&gt;Treat Local as an API compatibility layer, not a capacity simulator. It ignores&lt;br&gt;
provisioned throughput, never returns&lt;br&gt;
&lt;a href="https://dynotable.com/dynamodb-errors/provisionedthroughputexceededexception" rel="noopener noreferrer"&gt;&lt;code&gt;ProvisionedThroughputExceededException&lt;/code&gt;&lt;/a&gt;,&lt;br&gt;
and does not model on-demand burst behavior. A load test against Local tells you&lt;br&gt;
nothing about partition limits or adaptive capacity in AWS.&lt;/p&gt;

&lt;p&gt;Other gaps show up in integration tests if you do not plan for them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;DynamoDB Local&lt;/th&gt;
&lt;th&gt;AWS DynamoDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Billing / RCU / WCU&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Metered per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throttling&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;td&gt;Yes, at table/index limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTL deletion timing&lt;/td&gt;
&lt;td&gt;Best-effort, not SLA-bound&lt;/td&gt;
&lt;td&gt;Background sweeps on AWS schedule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB Streams delivery&lt;/td&gt;
&lt;td&gt;Simplified&lt;/td&gt;
&lt;td&gt;Full stream semantics + Lambda wiring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transactions across tables&lt;/td&gt;
&lt;td&gt;Supported in recent builds&lt;/td&gt;
&lt;td&gt;Full ACID with documented limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global Tables / PITR&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;td&gt;Production features&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your test asserts throttling, TTL expiry within seconds, or stream fan-out,&lt;br&gt;
run at least one suite against a disposable cloud table or LocalStack with the&lt;br&gt;
features you need enabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical local workflow
&lt;/h2&gt;

&lt;p&gt;Most teams wire Local into three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unit tests&lt;/strong&gt; — spin the container in CI, create tables in &lt;code&gt;beforeAll&lt;/code&gt;, tear
down in &lt;code&gt;afterAll&lt;/code&gt;. Keep fixtures small; marshal plain JSON through the
&lt;a href="https://dynotable.com/tools/dynamodb-json-converter" rel="noopener noreferrer"&gt;DynamoDB JSON converter&lt;/a&gt; when tests paste
attribute maps by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration tests&lt;/strong&gt; — exercise the same SDK client factory your app uses,
swapping only &lt;code&gt;endpoint&lt;/code&gt; and credentials. Assert on item shape and
conditional writes, not on consumed capacity (Local does not return
meaningful &lt;code&gt;ConsumedCapacity&lt;/code&gt; for budgeting).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual exploration&lt;/strong&gt; — connect DynoTable with a Local profile, stage edits,
and run PartiQL or key-condition queries before you deploy schema changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When you outgrow a single process — multiple services, S3 triggers, or IAM-style&lt;br&gt;
routing — graduate to &lt;a href="https://dynotable.com/learn/connect-dynamodb-local-localstack" rel="noopener noreferrer"&gt;LocalStack&lt;/a&gt; or a&lt;br&gt;
dev account. Local stays the fastest loop for "does my access pattern compile?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Seed data without hand-marshalling
&lt;/h2&gt;

&lt;p&gt;Loading ten fixture items from a JSON file is faster when you do not tag every&lt;br&gt;
value yourself. Paste the array into the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-json-converter" rel="noopener noreferrer"&gt;DynamoDB JSON converter&lt;/a&gt;, copy the marshalled&lt;br&gt;
output, and batch-write with &lt;code&gt;BatchWriteItem&lt;/code&gt; against &lt;code&gt;--endpoint-url&lt;br&gt;
http://localhost:8000&lt;/code&gt;. For update-heavy fixtures, assemble the&lt;br&gt;
&lt;code&gt;UpdateExpression&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; and paste the&lt;br&gt;
generated attribute maps into your test harness.&lt;/p&gt;

&lt;p&gt;DynoTable's item editor performs the same marshalling on commit — useful when a&lt;br&gt;
test failure leaves you staring at raw &lt;code&gt;{"S":...}&lt;/code&gt; blobs in the CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to leave Local
&lt;/h2&gt;

&lt;p&gt;Ship to a real table when you need any of the following measured on AWS itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capacity planning&lt;/strong&gt; — a 1 KB item queried 1,000 times per second consumes
roughly 250 eventually-consistent RCU per second on on-demand billing; Local
reports zero. Model that with the
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt; using sizes from the
&lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator" rel="noopener noreferrer"&gt;item-size calculator&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index propagation lag&lt;/strong&gt; — GSI reads are eventually consistent in production;
Local returns index rows quickly enough that stale-read bugs hide until deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-account IAM&lt;/strong&gt; — resource-scoped roles and condition keys only exist in
the cloud.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep Local for fast feedback on schema and expression syntax; validate cost and&lt;br&gt;
consistency assumptions against a staging table before production traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls worth scripting around
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forgotten &lt;code&gt;-sharedDb&lt;/code&gt;&lt;/strong&gt; — each unique credential pair gets an isolated
database; CI and your laptop look like different universes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root-owned volume without &lt;code&gt;user: root&lt;/code&gt;&lt;/strong&gt; — the SQLite backend fails silently
until you add the compose override from the section above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming Streams parity&lt;/strong&gt; — stream-enabled Lambdas need a cloud or LocalStack
target; Local alone will not exercise fan-out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty-string keys&lt;/strong&gt; — allowed on non-key attributes since 2020, still rejected
on keys; validate fixtures the same way you would in AWS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;Download DynoTable&lt;/a&gt;, add a profile pointed at &lt;code&gt;http://localhost:8000&lt;/code&gt;,&lt;br&gt;
and browse the tables you just created — the same grid, filter builder, and SQL&lt;br&gt;
Workbench you use in production, with zero AWS spend on the loop.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB GSI vs LSI</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:37:09 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-gsi-vs-lsi-4kd5</link>
      <guid>https://dev.to/dynotable/dynamodb-gsi-vs-lsi-4kd5</guid>
      <description>&lt;p&gt;Both a Global Secondary Index (GSI) and a Local Secondary Index (LSI) let you&lt;br&gt;
&lt;code&gt;Query&lt;/code&gt; by an attribute that isn't your table's key. They are not&lt;br&gt;
interchangeable — the differences decide which one a pattern needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the difference between a GSI and an LSI in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;A Global Secondary Index can use &lt;strong&gt;any top-level scalar&lt;/strong&gt; attribute (String, Number, or Binary) as its partition key, gets its own capacity, and can be added anytime — but only serves eventually consistent reads. A Local Secondary Index keeps the table's &lt;strong&gt;same&lt;/strong&gt; partition key with a different sort key, supports strongly consistent reads and shares the table's capacity, but must be created with the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The differences that matter
&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;GSI&lt;/th&gt;
&lt;th&gt;LSI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Partition key&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Any&lt;/strong&gt; scalar (S/N/B)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Same&lt;/strong&gt; as the table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sort key&lt;/td&gt;
&lt;td&gt;Any scalar (S/N/B)&lt;/td&gt;
&lt;td&gt;Any scalar (S/N/B)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When created&lt;/td&gt;
&lt;td&gt;Anytime&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Table-creation only&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consistency&lt;/td&gt;
&lt;td&gt;Eventual only&lt;/td&gt;
&lt;td&gt;Strong available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capacity&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Its own&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Shares the table's&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write propagation&lt;/td&gt;
&lt;td&gt;Async (eventual)&lt;/td&gt;
&lt;td&gt;Synchronous (atomic)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max per table&lt;/td&gt;
&lt;td&gt;20 (default, raisable)&lt;/td&gt;
&lt;td&gt;5 (hard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 GB partition cap&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (per PK)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A rule of thumb
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Need a &lt;strong&gt;different &lt;a href="https://dynotable.com/docs/dynamodb-glossary#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt;&lt;/strong&gt; (e.g. look up orders by &lt;code&gt;status&lt;/code&gt; instead of
&lt;code&gt;customer&lt;/code&gt;)? You need a &lt;strong&gt;GSI&lt;/strong&gt; — an LSI can't repartition.&lt;/li&gt;
&lt;li&gt;Need a &lt;strong&gt;second sort order within the same partition&lt;/strong&gt; — an LSI keeps the
table's exact partition key and only swaps in a different &lt;strong&gt;sort&lt;/strong&gt; key — decided
up front, with
&lt;a href="https://dynotable.com/docs/dynamodb-glossary#strongly-consistent" rel="noopener noreferrer"&gt;strongly-consistent&lt;/a&gt; reads? An &lt;strong&gt;LSI&lt;/strong&gt; fits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The choice collapses to one question — which key are you changing:&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%2Fy19rouup3ly2fcqbe9wa.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%2Fy19rouup3ly2fcqbe9wa.png" alt="DynamoDB GSI vs LSI" width="800" height="1037"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Different partition key forces a GSI; a different sort key on the same partition is the only case an LSI fits.&lt;/p&gt;

&lt;p&gt;In practice most teams reach for GSIs almost exclusively: they're addable later,&lt;br&gt;
independently scaled, and not subject to the 10 GB per-partition limit. Overload a&lt;br&gt;
single GSI's keys to serve several patterns — see&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're adding a GSI to kill a &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;Scan&lt;/a&gt;, remember it has&lt;br&gt;
its &lt;strong&gt;own&lt;/strong&gt; read/write capacity. Size that extra cost with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt;, and&lt;br&gt;
&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;try DynoTable&lt;/a&gt; to inspect an index's projected attributes before you&lt;br&gt;
commit to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked example: open tickets by priority
&lt;/h2&gt;

&lt;p&gt;Say you run a support desk. Tickets live under &lt;code&gt;PK = TEAM#7&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;SK = TICKET#8842&lt;/code&gt;. Two access patterns compete:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fetch one ticket by id&lt;/strong&gt; — &lt;code&gt;GetItem&lt;/code&gt; on the base table key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List open tickets for the team, highest priority first&lt;/strong&gt; — needs a different
sort order than creation time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pattern 2 cannot be a base-table &lt;code&gt;Query&lt;/code&gt; on &lt;code&gt;SK&lt;/code&gt; alone because the sort key is the&lt;br&gt;
ticket id, not status. Your options:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Keys&lt;/th&gt;
&lt;th&gt;Consistency on list&lt;/th&gt;
&lt;th&gt;When it fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GSI with &lt;code&gt;GSI1PK = TEAM#7&lt;/code&gt;, &lt;code&gt;GSI1SK = STATUS#open#P#1#TICKET#8842&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;New partition + sort&lt;/td&gt;
&lt;td&gt;Eventual&lt;/td&gt;
&lt;td&gt;Status changes often; add index after launch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LSI with same &lt;code&gt;PK&lt;/code&gt;, &lt;code&gt;LSI1SK = STATUS#open#P#1#...&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same partition, new sort&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;Index planned at table creation; status reads must be fresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter on base &lt;code&gt;Query&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Base keys only&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;Tiny partitions only — filters bill the whole partition&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a busy queue where agents refresh every few seconds, eventual consistency on&lt;br&gt;
a GSI is usually acceptable. For a financial ledger line that must reflect a&lt;br&gt;
status change before the API returns, serve the read from the base table or an&lt;br&gt;
LSI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write amplification in numbers
&lt;/h2&gt;

&lt;p&gt;Every base-table write propagates to each secondary index that projects the&lt;br&gt;
changed attributes. A ticket update that touches status and priority on a table&lt;br&gt;
with two GSIs and one LSI can fan out to four index writes plus the base write.&lt;/p&gt;

&lt;p&gt;On on-demand billing in &lt;code&gt;us-east-1&lt;/code&gt;, a 1 KB item write costs one WCU per&lt;br&gt;
destination. Updating one attribute on that item across one GSI adds roughly one&lt;br&gt;
more WCU to the request total — the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt; accepts item size and&lt;br&gt;
request rate if you want a monthly line item before you add indexes.&lt;/p&gt;

&lt;p&gt;LSIs share the table's throughput pool. A hot LSI sort key on a overloaded&lt;br&gt;
partition can throttle base-table writes even when the base partition key spread&lt;br&gt;
looks healthy — GSIs isolate that risk at the cost of separate capacity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10 GB LSI partition ceiling
&lt;/h2&gt;

&lt;p&gt;An LSI lives inside the same physical partition as its base-table partition key.&lt;br&gt;
AWS documents a &lt;strong&gt;10 GB per partition&lt;/strong&gt; limit for item collections that include&lt;br&gt;
LSI data. A tenant with millions of tickets under one &lt;code&gt;TEAM#7&lt;/code&gt; partition can hit&lt;br&gt;
that ceiling while the GSI-free base table still looks fine on paper.&lt;/p&gt;

&lt;p&gt;GSIs repartition on their own keys, so no single customer partition carries the&lt;br&gt;
entire LSI payload. That repartitioning is why most production schemas default&lt;br&gt;
to GSIs even when an LSI could technically serve the read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing at design time
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Lean GSI&lt;/th&gt;
&lt;th&gt;Lean LSI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Need a different partition key?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — LSI cannot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Must reads be strongly consistent?&lt;/td&gt;
&lt;td&gt;No — GSI is eventual&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index added after table exists?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — table creation only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partition may grow past ~10 GB of related items?&lt;/td&gt;
&lt;td&gt;Yes — GSI spreads&lt;/td&gt;
&lt;td&gt;Risky on LSI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Want isolated write scaling?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — shares table WCU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When you overload several access patterns onto one GSI, sketch the key templates&lt;br&gt;
first in the &lt;a href="https://dynotable.com/tools/dynamodb-single-table-design" rel="noopener noreferrer"&gt;single-table design tool&lt;/a&gt; —&lt;br&gt;
it surfaces which reads share an index and flags unsupported patterns before you&lt;br&gt;
pay for a backfill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect indexes before you commit
&lt;/h2&gt;

&lt;p&gt;In DynoTable, open the table's index picker and run the same filter against the&lt;br&gt;
base table and each GSI side by side. You see projected attributes immediately —&lt;br&gt;
attributes missing from the GSI result are not in the projection — and you can&lt;br&gt;
confirm whether an eventual GSI read is fresh enough for your UI.&lt;/p&gt;

&lt;p&gt;Build the &lt;code&gt;KeyConditionExpression&lt;/code&gt; for a candidate GSI 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 emit a&lt;br&gt;
full paginated program with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-query-builder" rel="noopener noreferrer"&gt;query builder&lt;/a&gt; to paste into a staging test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-gsi-eventually-consistent" rel="noopener noreferrer"&gt;GSI eventually consistent reads&lt;/a&gt; —
why a just-written item can be missing from a GSI query for a moment.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-index-projections" rel="noopener noreferrer"&gt;Index projections&lt;/a&gt; — what each index
physically stores.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-sparse-indexes" rel="noopener noreferrer"&gt;Sparse indexes&lt;/a&gt; — keep GSIs small by indexing
only items that carry a marker attribute.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>Singleton Items in DynamoDB</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:11:23 +0000</pubDate>
      <link>https://dev.to/dynotable/singleton-items-in-dynamodb-4lco</link>
      <guid>https://dev.to/dynotable/singleton-items-in-dynamodb-4lco</guid>
      <description>&lt;p&gt;A &lt;strong&gt;singleton item&lt;/strong&gt; is a single row with a fixed, hardcoded key that holds&lt;br&gt;
state for your whole application — not one record per user or per order, but&lt;br&gt;
&lt;strong&gt;one&lt;/strong&gt; record, period. Feature flags, a config blob, a global kill-switch:&lt;br&gt;
the kind of thing a relational app would keep in a one-row settings table.&lt;/p&gt;

&lt;p&gt;Coming from SQL, you'd reach for a &lt;code&gt;config&lt;/code&gt; table with &lt;code&gt;id = 1&lt;/code&gt; and a &lt;code&gt;SELECT * FROM config&lt;/code&gt;. In DynamoDB you do the same thing with a hardcoded partition&lt;br&gt;
key — and because you always know that key, you read it with a &lt;code&gt;GetItem&lt;/code&gt;, not&lt;br&gt;
a &lt;code&gt;Query&lt;/code&gt; or a &lt;code&gt;Scan&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a singleton item in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;A singleton item is a single DynamoDB row stored under a fixed, hardcoded key that holds global state for your whole application — feature flags, a config blob, a system-wide version — rather than one record per user or order. Because you always know the key, you read it with a &lt;code&gt;GetItem&lt;/code&gt; and update it with &lt;a href="https://dynotable.com/docs/dynamodb-glossary#update-expression" rel="noopener noreferrer"&gt;update&lt;/a&gt; plus condition expressions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A singleton is one item with a constant key.&lt;/strong&gt; You hardcode the &lt;code&gt;PK&lt;/code&gt;/&lt;code&gt;SK&lt;/code&gt;
in your code (e.g. &lt;code&gt;CONFIG#GLOBAL&lt;/code&gt;) instead of templating in a user or order id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read it with &lt;code&gt;GetItem&lt;/code&gt;, never &lt;code&gt;Scan&lt;/code&gt;.&lt;/strong&gt; You always know the full key, so a
point read has a flat, predictable cost (at most 1 RCU for a small item) — no filter, no table walk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a &lt;a href="https://dynotable.com/docs/dynamodb-glossary#hot-partition" rel="noopener noreferrer"&gt;hot key&lt;/a&gt; by definition.&lt;/strong&gt; Every request can touch the same partition,
so cache it and keep the item small; don't make it a write bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutate it safely with update + condition expressions&lt;/strong&gt;, not read-modify-write
in your app — that's where the lost-update race lives.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Recognize the pattern
&lt;/h2&gt;

&lt;p&gt;You have global state when the data isn't scoped to any one entity. A few tells:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A flag that's the same for everyone (&lt;code&gt;signup_enabled = false&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A blob of tunables your app reads on boot (rate limits, default quotas).&lt;/li&gt;
&lt;li&gt;A counter or version number for the whole system, not per-row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything scoped to a user, tenant, or order is &lt;strong&gt;not&lt;/strong&gt; a singleton — that's an&lt;br&gt;
ordinary item keyed by that entity's id. The singleton is the leftover global&lt;br&gt;
slice that has nowhere else to live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give it a constant key
&lt;/h2&gt;

&lt;p&gt;The whole pattern hinges on one decision. The key is a literal, not a template.&lt;br&gt;
For a global feature-flags item in an overloaded single table, pick a fixed&lt;br&gt;
prefix and a fixed value:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PK&lt;/th&gt;
&lt;th&gt;SK&lt;/th&gt;
&lt;th&gt;attributes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SETTINGS#APP&lt;/td&gt;
&lt;td&gt;FLAGS#V1&lt;/td&gt;
&lt;td&gt;signup_enabled, maintenance_mode, ai_search_enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;PK = "SETTINGS#APP"&lt;/code&gt; and &lt;code&gt;SK = "FLAGS#V1"&lt;/code&gt; are baked into the code. There's no&lt;br&gt;
user id, no tenant id — the application asks for exactly this item every time.&lt;br&gt;
That predictability is the point: a known key is a &lt;code&gt;GetItem&lt;/code&gt;, and a &lt;code&gt;GetItem&lt;/code&gt;&lt;br&gt;
is the cheapest, most consistent read DynamoDB offers.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;V1&lt;/code&gt; suffix is deliberate. If the flag schema changes shape later, you write&lt;br&gt;
a &lt;code&gt;FLAGS#V2&lt;/code&gt; item and flip readers over, instead of mutating the live one in&lt;br&gt;
place. Versioning the singleton key buys you a clean migration seam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read it with GetItem
&lt;/h2&gt;

&lt;p&gt;Because the key is fully known, you never &lt;code&gt;Query&lt;/code&gt; and you never &lt;code&gt;Scan&lt;/code&gt; for a&lt;br&gt;
singleton. A &lt;code&gt;Scan&lt;/code&gt; reads the whole table and filters client-side — the classic&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;Scan footgun&lt;/a&gt; — and it's absurd overkill for fetching&lt;br&gt;
one row you can address directly.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;GetItem&lt;/code&gt; against &lt;code&gt;SETTINGS#APP&lt;/code&gt; / &lt;code&gt;FLAGS#V1&lt;/code&gt; returns the flags in a single&lt;br&gt;
strongly- or eventually-consistent read. On on-demand in &lt;code&gt;us-east-1&lt;/code&gt;, AWS bills a&lt;br&gt;
&lt;code&gt;GetItem&lt;/code&gt; of an item ≤ &lt;strong&gt;4 KB&lt;/strong&gt; as &lt;strong&gt;0.5 RCU&lt;/strong&gt; eventually-consistent or &lt;strong&gt;1 RCU&lt;/strong&gt;&lt;br&gt;
strongly-consistent&lt;br&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadWriteCapacityMode.html" rel="noopener noreferrer"&gt;AWS read/write capacity docs&lt;/a&gt;).&lt;br&gt;
Keep the singleton small and that cost stays flat forever — bloating the config&lt;br&gt;
blob into a second &lt;strong&gt;4 KB&lt;/strong&gt; block doubles the billed read.&lt;/p&gt;

&lt;p&gt;On the read path, when the app boots or a request lands, you &lt;code&gt;GetItem&lt;/code&gt; the fixed&lt;br&gt;
key, you cache the result. The flow:&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%2Fk1m45z3l1rv2ol0b7nd4.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%2Fk1m45z3l1rv2ol0b7nd4.png" alt="Singleton Items in DynamoDB" width="800" height="763"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fixed key turns a global lookup into one point read with a built-in default path.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;no&lt;/code&gt; branch: a missing singleton should never crash you. Default to the&lt;br&gt;
safe value (feature &lt;strong&gt;off&lt;/strong&gt;, maintenance &lt;strong&gt;on&lt;/strong&gt;) so a first-deploy gap or a bad&lt;br&gt;
key fails closed, not open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update it without a race
&lt;/h2&gt;

&lt;p&gt;The trap is updating a singleton with read-modify-write in your app: you&lt;br&gt;
&lt;code&gt;GetItem&lt;/code&gt; the flags, flip one in memory, then &lt;code&gt;PutItem&lt;/code&gt; the whole thing back.&lt;br&gt;
Two concurrent writers both read the old item and the second &lt;code&gt;Put&lt;/code&gt; clobbers the&lt;br&gt;
first's change. Lost update.&lt;/p&gt;

&lt;p&gt;Two DynamoDB features kill the race without app-side locking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary#update-expression" rel="noopener noreferrer"&gt;Update expressions&lt;/a&gt;&lt;/strong&gt; mutate one attribute server-side, leaving the rest
untouched. No need to re-&lt;code&gt;Put&lt;/code&gt; the whole item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary#condition-expression" rel="noopener noreferrer"&gt;Condition expressions&lt;/a&gt;&lt;/strong&gt; make the write succeed only if the item still looks
the way you expect, so a stale write is rejected with
&lt;code&gt;ConditionalCheckFailedException&lt;/code&gt;
(&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html" rel="noopener noreferrer"&gt;AWS condition expression docs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To flip one flag, target just that attribute with a &lt;code&gt;SET&lt;/code&gt; and guard it with a&lt;br&gt;
version bump so concurrent writers can't trample each other:&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=SETTINGS#APP  SK=FLAGS#V1&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 signup_enabled = :on, schema_version = :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  schema_version = :current&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;If two writers race, the second one's &lt;code&gt;schema_version = :current&lt;/code&gt; check fails&lt;br&gt;
and it retries against the fresh value. You can scaffold the names, values, and&lt;br&gt;
this exact expression shape 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; before wiring&lt;br&gt;
it into code. For a deeper look at the operators, see the&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-update-expressions" rel="noopener noreferrer"&gt;update-expression idioms&lt;/a&gt; guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mind the hot key
&lt;/h2&gt;

&lt;p&gt;A singleton is, by construction, a &lt;strong&gt;hot key&lt;/strong&gt; — every part of your app may read&lt;br&gt;
the same partition. That's fine for reads if you cache, but it's the one real&lt;br&gt;
risk of the pattern.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache aggressively.&lt;/strong&gt; Read the flags once per process (or per N seconds), not
on every request. The singleton's value is the cheapest thing to memoize.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't make it a write hot spot.&lt;/strong&gt; A flag toggled by an admin a few times a day
is nothing. A singleton you increment on every request is a partition-throughput
bottleneck — that's a counter problem, not a singleton.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep it small.&lt;/strong&gt; Read cost scales with item size in 4 KB blocks. A bloated
config blob makes every boot more expensive than it needs to be.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you genuinely need a high-write global counter, the singleton is the wrong&lt;br&gt;
shape — shard it across N items and sum on read. That's a different pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Singleton vs per-entity item
&lt;/h2&gt;

&lt;p&gt;The line is simply &lt;em&gt;what the data is scoped to&lt;/em&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;Singleton item&lt;/th&gt;
&lt;th&gt;Per-entity item&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Key&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hardcoded constant (&lt;code&gt;SETTINGS#APP&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Templated with an id (&lt;code&gt;USER#42&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;How many&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Exactly one&lt;/td&gt;
&lt;td&gt;One per user / order / tenant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Typical read&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GetItem&lt;/code&gt; on the known key&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GetItem&lt;/code&gt; or &lt;code&gt;Query&lt;/code&gt; by entity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Whole application&lt;/td&gt;
&lt;td&gt;A single entity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Use for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Global flags, config, system version&lt;/td&gt;
&lt;td&gt;Profiles, orders, anything per-id&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you find yourself wanting &lt;em&gt;two&lt;/em&gt; singletons of the same kind, you don't have a&lt;br&gt;
singleton — you have a per-entity item and the entity is the thing you forgot to&lt;br&gt;
key by (per-tenant config, say).&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't &lt;code&gt;Scan&lt;/code&gt; for it.&lt;/strong&gt; You know the key; address it directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't read-modify-write it.&lt;/strong&gt; Use update + condition expressions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't let it go missing silently.&lt;/strong&gt; Default to the safe value on a cache miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't overload it with high-frequency writes.&lt;/strong&gt; That's a sharded-counter job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The singleton lives comfortably inside a&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt; — it's just one more item&lt;br&gt;
collection with a fixed key alongside your entity rows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;Try DynoTable&lt;/a&gt; to browse your table, find the singleton row by its&lt;br&gt;
fixed key, and edit flags by hand while you build the write path.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB Single-Table Design: The Complete Guide</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:10:51 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-single-table-design-the-complete-guide-5f7d</link>
      <guid>https://dev.to/dynotable/dynamodb-single-table-design-the-complete-guide-5f7d</guid>
      <description>&lt;p&gt;Coming from SQL, the instinct is one table per entity: &lt;code&gt;customers&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;order_items&lt;/code&gt;. In DynamoDB that instinct is usually wrong. A single table that&lt;br&gt;
stores &lt;strong&gt;every&lt;/strong&gt; entity, distinguished by overloaded key prefixes, lets you fetch&lt;br&gt;
a parent and its children in &lt;strong&gt;one&lt;/strong&gt; &lt;code&gt;Query&lt;/code&gt; — no joins, no N+1.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is single-table design in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;Single-table design stores every entity — customers, orders, order items — in one&lt;br&gt;
DynamoDB table, distinguished by overloaded &lt;a href="https://dynotable.com/docs/dynamodb-glossary#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt; and&lt;br&gt;
sort key prefixes. Because the keys are designed around your access patterns&lt;br&gt;
rather than your entities, a parent and all its children live in one&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-glossary#item-collection" rel="noopener noreferrer"&gt;item collection&lt;/a&gt; and come back in a single &lt;code&gt;Query&lt;/code&gt; — no joins,&lt;br&gt;
no N+1 reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;Pick generic key names (&lt;code&gt;PK&lt;/code&gt;, &lt;code&gt;SK&lt;/code&gt;) and encode the entity type in the value:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PK&lt;/th&gt;
&lt;th&gt;SK&lt;/th&gt;
&lt;th&gt;attributes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CUSTOMER#42&lt;/td&gt;
&lt;td&gt;PROFILE&lt;/td&gt;
&lt;td&gt;name, email, plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CUSTOMER#42&lt;/td&gt;
&lt;td&gt;ORDER#2026-001&lt;/td&gt;
&lt;td&gt;total, status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CUSTOMER#42&lt;/td&gt;
&lt;td&gt;ORDER#2026-002&lt;/td&gt;
&lt;td&gt;total, status&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now one &lt;code&gt;Query PK = "CUSTOMER#42"&lt;/code&gt; returns the profile &lt;strong&gt;and&lt;/strong&gt; every order in a&lt;br&gt;
single billed read. &lt;code&gt;SK begins_with "ORDER#"&lt;/code&gt; narrows it to just the orders.&lt;/p&gt;

&lt;p&gt;Visually, the overloaded items stack under one &lt;a href="https://dynotable.com/docs/dynamodb-glossary#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt; as a single &lt;a href="https://dynotable.com/docs/dynamodb-glossary#item-collection" rel="noopener noreferrer"&gt;item collection&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%2Fmyp07i395i12abdmgpew.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%2Fmyp07i395i12abdmgpew.png" alt="DynamoDB Single-Table Design: The Complete Guide" width="800" height="1312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One read of the partition hands back the customer and every order together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start from access patterns, not nouns
&lt;/h2&gt;

&lt;p&gt;Single-table design begins with a numbered list of reads and writes your application&lt;br&gt;
actually performs — "show customer profile", "list open orders for customer",&lt;br&gt;
"lookup order by id globally" — not with an ER diagram of entity boxes. Each pattern&lt;br&gt;
must compile to a &lt;code&gt;Query&lt;/code&gt; or &lt;code&gt;GetItem&lt;/code&gt; on the base table or on an overloaded GSI.&lt;br&gt;
If a pattern needs a &lt;code&gt;Scan&lt;/code&gt;, the keys are wrong; fix the model before shipping.&lt;/p&gt;

&lt;p&gt;Write the list down explicitly. A three-pattern app might look like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch customer + all orders for one customer id (high volume).&lt;/li&gt;
&lt;li&gt;List all open orders across customers, sorted by date (moderate volume).&lt;/li&gt;
&lt;li&gt;Fetch one order by its public order id (high volume).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pattern 1 is a base-table &lt;code&gt;Query&lt;/code&gt; on &lt;code&gt;PK = CUSTOMER#&amp;lt;id&amp;gt;&lt;/code&gt;. Pattern 2 lands on&lt;br&gt;
&lt;code&gt;GSI1PK = STATUS#OPEN&lt;/code&gt; with &lt;code&gt;GSI1SK&lt;/code&gt; as the date. Pattern 3 might use&lt;br&gt;
&lt;code&gt;PK = ORDER#&amp;lt;id&amp;gt;, SK = METADATA&lt;/code&gt; as a separate item collection from pattern 1's&lt;br&gt;
&lt;code&gt;CUSTOMER#&amp;lt;id&amp;gt; / ORDER#&amp;lt;id&amp;gt;&lt;/code&gt; rows — duplicate the order summary twice if both&lt;br&gt;
access paths are hot, or accept a second &lt;code&gt;Query&lt;/code&gt; when one path is rare.&lt;/p&gt;

&lt;p&gt;The free &lt;a href="https://dynotable.com/tools/dynamodb-single-table-design" rel="noopener noreferrer"&gt;Single-Table Design tool&lt;/a&gt; accepts&lt;br&gt;
that pattern list and emits a PK/SK/GSI layout with example items, CreateTable JSON,&lt;br&gt;
and per-pattern cost hints so you can compare alternatives before committing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overloaded GSIs
&lt;/h2&gt;

&lt;p&gt;The same trick works on indexes. Put a generic &lt;code&gt;GSI1PK&lt;/code&gt;/&lt;code&gt;GSI1SK&lt;/code&gt; on items, and a&lt;br&gt;
single &lt;a href="https://dynotable.com/docs/dynamodb-glossary#gsi" rel="noopener noreferrer"&gt;GSI&lt;/a&gt; serves multiple access patterns depending on what each item writes&lt;br&gt;
into those attributes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PK&lt;/th&gt;
&lt;th&gt;SK&lt;/th&gt;
&lt;th&gt;GSI1PK&lt;/th&gt;
&lt;th&gt;GSI1SK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ORDER#001&lt;/td&gt;
&lt;td&gt;METADATA&lt;/td&gt;
&lt;td&gt;STATUS#OPEN&lt;/td&gt;
&lt;td&gt;2026-01-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ORDER#002&lt;/td&gt;
&lt;td&gt;METADATA&lt;/td&gt;
&lt;td&gt;STATUS#OPEN&lt;/td&gt;
&lt;td&gt;2026-01-05&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now &lt;code&gt;Query GSI1 WHERE GSI1PK = "STATUS#OPEN"&lt;/code&gt; lists open orders by date — a&lt;br&gt;
pattern the base table can't answer. A different entity can reuse &lt;code&gt;GSI1&lt;/code&gt; with its&lt;br&gt;
own meaning (e.g. &lt;code&gt;CATEGORY#books&lt;/code&gt;). One index, many queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Many-to-many: the adjacency list
&lt;/h2&gt;

&lt;p&gt;For relationships (a user in many teams, a team with many users), write the edge&lt;br&gt;
&lt;strong&gt;twice&lt;/strong&gt; with the ids swapped: &lt;code&gt;PK=USER#1, SK=TEAM#9&lt;/code&gt; and &lt;code&gt;PK=TEAM#9, SK=USER#1&lt;/code&gt;.&lt;br&gt;
Querying either side lists the other — the DynamoDB stand-in for a join table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read cost: one Query vs many GetItems
&lt;/h2&gt;

&lt;p&gt;The billing win is real when children live under the parent's partition key. Suppose&lt;br&gt;
a customer profile (1 KB) and twelve order summaries (1 KB each) share&lt;br&gt;
&lt;code&gt;PK = CUSTOMER#42&lt;/code&gt;. One eventually-consistent &lt;code&gt;Query&lt;/code&gt; on that partition reads&lt;br&gt;
13 KB total → &lt;strong&gt;2&lt;/strong&gt; read request units (13 KB rounds up to four 4 KB blocks at 0.5&lt;br&gt;
RCU each). Fetching the same thirteen items with thirteen &lt;code&gt;GetItem&lt;/code&gt; calls meters&lt;br&gt;
the same per-item rounding individually → &lt;strong&gt;7&lt;/strong&gt; units (thirteen × 0.5 RCU minimum&lt;br&gt;
one block per item). Same data, more than triple the read units — before network&lt;br&gt;
round trips.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Access style&lt;/th&gt;
&lt;th&gt;Round trips&lt;/th&gt;
&lt;th&gt;Read units (13 × 1 KB items, EC)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Query PK = CUSTOMER#42&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13 × &lt;code&gt;GetItem&lt;/code&gt; by full primary key&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Scan&lt;/code&gt; + filter on customer id&lt;/td&gt;
&lt;td&gt;1+ pages&lt;/td&gt;
&lt;td&gt;whole table&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Numbers shift with item size — profile blobs at 6 KB each cross two 4 KB blocks&lt;br&gt;
per item — which is why the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator" rel="noopener noreferrer"&gt;item-size calculator&lt;/a&gt; sits next to the design&lt;br&gt;
tool. Writes obey a &lt;strong&gt;1 KB&lt;/strong&gt; rounding boundary (one WCU per kilobyte, doubled inside&lt;br&gt;
transactions); fat order rows cost more to denormalize onto the parent partition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entity typing and sparse indexes
&lt;/h2&gt;

&lt;p&gt;Most teams add a string &lt;code&gt;type&lt;/code&gt; or &lt;code&gt;entity&lt;/code&gt; attribute (&lt;code&gt;CUSTOMER&lt;/code&gt;, &lt;code&gt;ORDER&lt;/code&gt;, &lt;code&gt;LINE&lt;/code&gt;)&lt;br&gt;
so application code can branch when a &lt;code&gt;Query&lt;/code&gt; returns mixed sort-key prefixes.&lt;br&gt;
GSIs can be &lt;strong&gt;sparse&lt;/strong&gt;: only items that populate &lt;code&gt;GSI1PK&lt;/code&gt;/&lt;code&gt;GSI1SK&lt;/code&gt; appear on the&lt;br&gt;
index, so a customer profile row with those attributes empty does not consume index&lt;br&gt;
storage or show up in unrelated queries.&lt;/p&gt;

&lt;p&gt;When two entity types share an index, document what each prefix means (&lt;code&gt;STATUS#&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;CATEGORY#&lt;/code&gt;, &lt;code&gt;EMAIL#&lt;/code&gt;) in the same place you document the base-table prefixes.&lt;br&gt;
Future you should not have to reverse-engineer meaning from CloudWatch graphs alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to single-table
&lt;/h2&gt;

&lt;p&gt;It isn't free. One overloaded table is harder to reason about, harder to evolve,&lt;br&gt;
and analytics-hostile. If your access patterns are genuinely unknown or change&lt;br&gt;
constantly, or the data is mostly analytical, separate tables (or a different&lt;br&gt;
store) can be the saner call. Single-table wins when the patterns are &lt;strong&gt;known and&lt;br&gt;
high-volume&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;Signal&lt;/th&gt;
&lt;th&gt;Lean separate tables&lt;/th&gt;
&lt;th&gt;Lean single-table&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Access patterns documented and stable&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many unrelated domains with no shared reads&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team new to DynamoDB, needs obvious table names&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parent + children fetched together constantly&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy ad-hoc analytics across arbitrary joins&lt;/td&gt;
&lt;td&gt;✓ (or warehouse)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hot multi-entity dashboard on one partition key&lt;/td&gt;
&lt;td&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;Regulatory or organizational boundaries (different data owners, distinct backup&lt;br&gt;
policies) can also justify multiple tables even when a single-table model fits&lt;br&gt;
access patterns technically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost of the wrong shape
&lt;/h2&gt;

&lt;p&gt;Modelling as separate tables forces a &lt;code&gt;Scan&lt;/code&gt; or client-side join to reassemble a&lt;br&gt;
customer, and that is the &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;Scan footgun&lt;/a&gt;. Model the access&lt;br&gt;
patterns first, then design keys to make each one a &lt;code&gt;Query&lt;/code&gt;. (For the ad-hoc&lt;br&gt;
cross-entity question you never modeled for, &lt;a href="https://dynotable.com/docs/dynamodb-sql-workbench" rel="noopener noreferrer"&gt;DynoTable's SQL&lt;br&gt;
Workbench&lt;/a&gt; runs the &lt;code&gt;JOIN&lt;/code&gt; client-side — exploration&lt;br&gt;
doesn't have to wait for a re-model.)&lt;/p&gt;

&lt;p&gt;Evolving a live single-table layout usually means &lt;strong&gt;adding&lt;/strong&gt; GSIs or new item&lt;br&gt;
types, not renaming &lt;code&gt;PK&lt;/code&gt;/&lt;code&gt;SK&lt;/code&gt; semantics in place. Treat prefix contracts like API&lt;br&gt;
versions: add &lt;code&gt;ORDER#v2#&lt;/code&gt; rather than repurpose &lt;code&gt;ORDER#&lt;/code&gt; mid-flight. Migrations&lt;br&gt;
that rewrite every item's keys belong in a controlled backfill with dual-write&lt;br&gt;
periods, not in a Friday-afternoon script.&lt;/p&gt;

&lt;p&gt;Sketch the design itself with the free&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-single-table-design" rel="noopener noreferrer"&gt;Single-Table Design tool&lt;/a&gt; — it turns your&lt;br&gt;
access-pattern list into a PK/SK/GSI plan with example items and cost hints.&lt;br&gt;
Estimate what these items cost per read with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator" rel="noopener noreferrer"&gt;item-size &amp;amp; capacity calculator&lt;/a&gt;, and&lt;br&gt;
compose the resulting &lt;code&gt;Query&lt;/code&gt; programs in the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-query-builder" rel="noopener noreferrer"&gt;query builder&lt;/a&gt; when you're ready to ship SDK code.&lt;br&gt;
Read &lt;a href="https://dynotable.com/learn/dynamodb-composite-primary-key" rel="noopener noreferrer"&gt;composite primary keys&lt;/a&gt; if partition +&lt;br&gt;
sort key mechanics still feel fuzzy — single-table design is mostly disciplined&lt;br&gt;
composite-key usage with shared attribute names.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;Try DynoTable&lt;/a&gt; to browse a single-table schema and see the overloaded&lt;br&gt;
collections side by side — partition inspector view lines up sort-key order with&lt;br&gt;
the access patterns you sketched.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>Why a DynamoDB Scan Is Slow and Expensive</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:10:49 +0000</pubDate>
      <link>https://dev.to/dynotable/why-a-dynamodb-scan-is-slow-and-expensive-4l51</link>
      <guid>https://dev.to/dynotable/why-a-dynamodb-scan-is-slow-and-expensive-4l51</guid>
      <description>&lt;p&gt;A &lt;code&gt;Scan&lt;/code&gt; reads &lt;strong&gt;every item in the table&lt;/strong&gt; and only filters afterward. It is&lt;br&gt;
the operation you reach for out of SQL muscle memory, and the one that quietly&lt;br&gt;
runs up your bill while making your latency worse than the RDS box you left.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why is my DynamoDB Scan slow and expensive?
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Scan&lt;/code&gt; reads every item in the table before the &lt;code&gt;FilterExpression&lt;/code&gt; runs, so&lt;br&gt;
you pay to read the whole table no matter how few rows come back, and it gets&lt;br&gt;
slower as the table grows. The fix is almost always a keyed &lt;code&gt;Query&lt;/code&gt; — model the&lt;br&gt;
access pattern around a key so DynamoDB touches one partition instead of&lt;br&gt;
everything.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;Scan&lt;/code&gt; reads the whole table, every time.&lt;/strong&gt; Size, not your result count,
decides what you pay and how long it takes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;FilterExpression&lt;/code&gt; is a lie about cost.&lt;/strong&gt; It runs &lt;em&gt;after&lt;/em&gt; the read is
metered, so returning 12 items can bill for reading 12 million.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;Scan&lt;/code&gt; gets slower as you grow.&lt;/strong&gt; A keyed &lt;code&gt;Query&lt;/code&gt; stays flat — it touches
one partition no matter how big the table gets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix is almost always modeling, not tuning.&lt;/strong&gt; If you &lt;code&gt;Scan&lt;/code&gt; to answer a
routine question, you are missing a key.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What a Scan actually does
&lt;/h2&gt;

&lt;p&gt;Coming from SQL, &lt;code&gt;SELECT * FROM events WHERE type = 'checkout'&lt;/code&gt; feels free —&lt;br&gt;
the engine has an index, or it doesn't, but either way you get rows back. In&lt;br&gt;
DynamoDB there is no query planner deciding that for you.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Scan&lt;/code&gt; walks the entire table sequentially, 1 MB at a time, and hands each&lt;br&gt;
page to your &lt;code&gt;FilterExpression&lt;/code&gt;. Whatever the filter rejects is still read,&lt;br&gt;
still metered, and still on your bill. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Scan.html" rel="noopener noreferrer"&gt;AWS: Scanning tables&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;That is the trap. The filter looks like a &lt;code&gt;WHERE&lt;/code&gt; clause, but it changes the&lt;br&gt;
result set, never the cost. A &lt;code&gt;Scan&lt;/code&gt; consumes the same read capacity whether or&lt;br&gt;
not a filter is present. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Scan.html" rel="noopener noreferrer"&gt;AWS: Scanning tables&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;
  
  
  Count the read units
&lt;/h2&gt;

&lt;p&gt;DynamoDB meters reads in &lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary#capacity-unit" rel="noopener noreferrer"&gt;read capacity units&lt;/a&gt; (RCUs)&lt;/strong&gt;. One RCU buys a single&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-glossary#strongly-consistent" rel="noopener noreferrer"&gt;strongly consistent&lt;/a&gt; read of an item up to 4 KB; &lt;a href="https://dynotable.com/docs/dynamodb-glossary#eventually-consistent" rel="noopener noreferrer"&gt;eventually consistent&lt;/a&gt; reads&lt;br&gt;
cost half that. Bigger items round up to the next 4 KB. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadWriteCapacityMode.html" rel="noopener noreferrer"&gt;AWS: Read/write&lt;br&gt;
capacity mode&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Take an analytics table, &lt;code&gt;ProductEvents&lt;/code&gt;. Each row is one tracked event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PK  = "TENANT#acme"
SK  = "TS#2026-06-23T14:08:55Z#evt_9f3a"
attrs: eventType, sessionId, userId, payloadBytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Say it holds &lt;strong&gt;2,000,000&lt;/strong&gt; events, each ~1 KB, all under one busy tenant. You&lt;br&gt;
want today's checkouts. The reflexive move:&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;Scan ProductEvents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FilterExpression: eventType = "checkout"&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 filter might return 40 rows. But the &lt;code&gt;Scan&lt;/code&gt; read all 2,000,000 items&lt;br&gt;
first. At ~1 KB each (1 RCU per 4 KB, eventually consistent ≈ 0.5 RCU per 4 KB),&lt;br&gt;
you metered roughly &lt;strong&gt;250,000 RCUs&lt;/strong&gt; — and paged through ~2 GB of data — to&lt;br&gt;
hand back 40 items.&lt;/p&gt;

&lt;p&gt;Now model the access pattern as a key and &lt;code&gt;Query&lt;/code&gt; it instead:&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 ProductEvents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PK = "TENANT#acme"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AND SK begins_with "TS#2026-06-23"&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;This reads only the matched slice of one partition. If those 40 checkout rows&lt;br&gt;
plus the day's other events come to ~2 MB, you pay for ~2 MB of reads, not&lt;br&gt;
2 GB. Same answer, a tiny fraction of the cost — and the latency stays flat&lt;br&gt;
as the table grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scan vs Query, metered
&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;Scan + filter&lt;/th&gt;
&lt;th&gt;Keyed Query&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reads&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Every&lt;/strong&gt; item in the table&lt;/td&gt;
&lt;td&gt;One partition, narrowed by SK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billed capacity&lt;/td&gt;
&lt;td&gt;Whole table, before the filter&lt;/td&gt;
&lt;td&gt;Only the items in your slice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Our example&lt;/td&gt;
&lt;td&gt;~250,000 RCUs (~2 GB)&lt;/td&gt;
&lt;td&gt;a few hundred RCUs (~2 MB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;Grows with table size&lt;/td&gt;
&lt;td&gt;Flat as the table grows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Result count&lt;/td&gt;
&lt;td&gt;Decides nothing about cost&lt;/td&gt;
&lt;td&gt;Matches what you pay for&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On a &lt;code&gt;Scan&lt;/code&gt;, your result count and your bill are&lt;br&gt;
unrelated. On a &lt;code&gt;Query&lt;/code&gt;, they track each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide before you Scan
&lt;/h2&gt;

&lt;p&gt;Most accidental &lt;code&gt;Scan&lt;/code&gt;s come from one question: &lt;em&gt;can I name the partition I&lt;br&gt;
need?&lt;/em&gt; If yes, it is a &lt;code&gt;Query&lt;/code&gt;. If no, the fix is a key, not a bigger filter.&lt;/p&gt;

&lt;p&gt;Here is the decision in flow form.&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%2Fvofo7up89vqzvrevda29.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%2Fvofo7up89vqzvrevda29.png" alt="Why a DynamoDB Scan Is Slow and Expensive"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The path almost always ends at &lt;code&gt;Query&lt;/code&gt;; you only fall through to &lt;code&gt;Scan&lt;/code&gt; when no&lt;br&gt;
key — present or addable — fits the access pattern.&lt;/p&gt;

&lt;p&gt;If the pattern is real and recurring but the base table can't key it, that is&lt;br&gt;
the signal to add a &lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi" rel="noopener noreferrer"&gt;Global Secondary Index&lt;/a&gt; so the question&lt;br&gt;
becomes a &lt;code&gt;Query&lt;/code&gt;. Modeling your keys around your access patterns up front is&lt;br&gt;
the whole game — see &lt;a href="https://dynotable.com/learn/dynamodb-single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the keyed query, not a filter
&lt;/h2&gt;

&lt;p&gt;When you do need a condition beyond the key, build it deliberately rather than&lt;br&gt;
dumping everything into a &lt;code&gt;FilterExpression&lt;/code&gt;. The&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; generates the&lt;br&gt;
&lt;code&gt;KeyConditionExpression&lt;/code&gt; and attribute placeholders for you, so the partition&lt;br&gt;
and sort key do the narrowing — before DynamoDB meters the read, not after.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KeyConditionExpression: PK = :tenant AND begins_with(SK, :day)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When a Scan is actually fine
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Scan&lt;/code&gt; is the wrong default for routine queries. It's the right tool when&lt;br&gt;
you genuinely mean "read everything":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One-off exports&lt;/strong&gt; or backfills run by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiny config / lookup tables&lt;/strong&gt; where the whole table is a few KB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background jobs&lt;/strong&gt; that page the full table on purpose. Split those across
workers with &lt;code&gt;Segment&lt;/code&gt; / &lt;code&gt;TotalSegments&lt;/code&gt; — a &lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary#parallel-scan" rel="noopener noreferrer"&gt;parallel scan&lt;/a&gt;&lt;/strong&gt; — instead of
one long sequential crawl. (&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Scan.html" rel="noopener noreferrer"&gt;AWS: Scanning tables&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And note PartiQL doesn't save you: &lt;code&gt;SELECT * FROM ProductEvents WHERE&lt;br&gt;
eventType = 'checkout'&lt;/code&gt; with no key predicate compiles straight to a &lt;code&gt;Scan&lt;/code&gt;.&lt;br&gt;
It's the same footgun in SQL clothing. (See &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;Query vs Scan&lt;/a&gt;&lt;br&gt;
for the full breakdown.)&lt;/p&gt;

&lt;p&gt;When you truly need cross-item analytics — a &lt;code&gt;GROUP BY&lt;/code&gt;, a &lt;code&gt;JOIN&lt;/code&gt;, an aggregate&lt;br&gt;
DynamoDB can't express — DynoTable's SQL Workbench runs them client-side over a&lt;br&gt;
bounded result set, instead of hammering the table with a full &lt;code&gt;Scan&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Estimate what either pattern costs with the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt;, read&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;Query vs Scan&lt;/a&gt; for the API-level contrast, and&lt;br&gt;
&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt; to run these against your own tables and watch&lt;br&gt;
how many items each approach actually reads.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>DynamoDB ReturnValues: Get the Old or New Item</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Tue, 11 Aug 2026 09:45:44 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-returnvalues-get-the-old-or-new-item-d2j</link>
      <guid>https://dev.to/dynotable/dynamodb-returnvalues-get-the-old-or-new-item-d2j</guid>
      <description>&lt;p&gt;By default a DynamoDB write returns nothing but success. But you often need the data&lt;br&gt;
&lt;em&gt;around&lt;/em&gt; the write — the value before you changed it, or the fresh value after. The&lt;br&gt;
naive fix is a second &lt;code&gt;GetItem&lt;/code&gt;, which is an extra round trip &lt;strong&gt;and&lt;/strong&gt; a race: someone&lt;br&gt;
else can write in between. DynamoDB avoids both with the &lt;strong&gt;&lt;code&gt;ReturnValues&lt;/code&gt;&lt;/strong&gt; parameter,&lt;br&gt;
which hands back the old or new item atomically as part of the write itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  What does ReturnValues do in DynamoDB?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ReturnValues&lt;/code&gt; tells a DynamoDB write to hand back the item as part of the same call, so you skip a second &lt;code&gt;GetItem&lt;/code&gt; and the race it creates. &lt;code&gt;PutItem&lt;/code&gt; and &lt;code&gt;DeleteItem&lt;/code&gt; accept &lt;code&gt;NONE&lt;/code&gt; or &lt;code&gt;ALL_OLD&lt;/code&gt;; &lt;code&gt;UpdateItem&lt;/code&gt; accepts all five (&lt;code&gt;NONE&lt;/code&gt;, &lt;code&gt;ALL_OLD&lt;/code&gt;, &lt;code&gt;UPDATED_OLD&lt;/code&gt;, &lt;code&gt;ALL_NEW&lt;/code&gt;, &lt;code&gt;UPDATED_NEW&lt;/code&gt;), returning old or new values atomically.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReturnValues&lt;/code&gt; returns the item as part of the write&lt;/strong&gt; — no second read, no race.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NONE&lt;/code&gt;&lt;/strong&gt; (default) — return nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ALL_OLD&lt;/code&gt;&lt;/strong&gt; — the entire item &lt;em&gt;as it was before&lt;/em&gt; the write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UPDATED_OLD&lt;/code&gt;&lt;/strong&gt; — only the attributes the update changed, &lt;em&gt;before&lt;/em&gt; values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ALL_NEW&lt;/code&gt;&lt;/strong&gt; — the entire item &lt;em&gt;after&lt;/em&gt; the write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UPDATED_NEW&lt;/code&gt;&lt;/strong&gt; — only the changed attributes, &lt;em&gt;after&lt;/em&gt; values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PutItem&lt;/code&gt;/&lt;code&gt;DeleteItem&lt;/code&gt; accept only &lt;code&gt;NONE&lt;/code&gt; or &lt;code&gt;ALL_OLD&lt;/code&gt;;&lt;/strong&gt; &lt;code&gt;UpdateItem&lt;/code&gt; accepts all
five.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The problem: you need the value you just overwrote
&lt;/h2&gt;

&lt;p&gt;Say you run a support desk and an agent changes a ticket's status from &lt;code&gt;open&lt;/code&gt; to&lt;br&gt;
&lt;code&gt;pending&lt;/code&gt;. Your audit log needs to record &lt;strong&gt;what the status was before&lt;/strong&gt; the change.&lt;br&gt;
Without &lt;code&gt;ReturnValues&lt;/code&gt; you'd:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;GetItem&lt;/code&gt; to read the current status,&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UpdateItem&lt;/code&gt; to set the new one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Between steps 1 and 2 another agent could change the status — now your audit log records&lt;br&gt;
a stale "before" value. Worse, it's two calls for one logical operation. &lt;code&gt;ReturnValues&lt;/code&gt;&lt;br&gt;
collapses it into a single atomic &lt;code&gt;UpdateItem&lt;/code&gt; that returns the old status as it&lt;br&gt;
actually was at write time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The five options, and when to use each
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;UpdateItem&lt;/code&gt; supports the full set; the choice is &lt;em&gt;what slice of the item&lt;/em&gt; and &lt;em&gt;which&lt;br&gt;
side of the write&lt;/em&gt; you need:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;ReturnValues&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Returns&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NONE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;you don't need the item back (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALL_OLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;whole item, pre-write&lt;/td&gt;
&lt;td&gt;auditing / "what did I just replace?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UPDATED_OLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;changed attrs, pre-write&lt;/td&gt;
&lt;td&gt;you only care about the fields you touched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALL_NEW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;whole item, post-write&lt;/td&gt;
&lt;td&gt;you need the fresh full item to return to a caller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UPDATED_NEW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;changed attrs, post-write&lt;/td&gt;
&lt;td&gt;reading back a counter/value you just incremented&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;UPDATED_NEW&lt;/code&gt; is the everyday hero: increment a counter with an&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-update-expressions" rel="noopener noreferrer"&gt;update expression&lt;/a&gt; and read the new total back in&lt;br&gt;
the same call, no race. For the support-ticket audit, &lt;code&gt;ALL_OLD&lt;/code&gt; (or &lt;code&gt;UPDATED_OLD&lt;/code&gt; if&lt;br&gt;
you only log the status field) captures the pre-change state atomically.&lt;/p&gt;

&lt;p&gt;Note the asymmetry: &lt;strong&gt;&lt;code&gt;PutItem&lt;/code&gt; and &lt;code&gt;DeleteItem&lt;/code&gt; only support &lt;code&gt;NONE&lt;/code&gt; and &lt;code&gt;ALL_OLD&lt;/code&gt;&lt;/strong&gt; —&lt;br&gt;
there's no "new" value to return for a delete, and a put's new value is just what you&lt;br&gt;
sent. Only &lt;code&gt;UpdateItem&lt;/code&gt;, which mutates in place, offers all five.&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html" rel="noopener noreferrer"&gt;AWS documents&lt;/a&gt;&lt;br&gt;
the exact matrix.&lt;/p&gt;
&lt;h2&gt;
  
  
  Writing the update in DynoTable
&lt;/h2&gt;

&lt;p&gt;Assemble the &lt;code&gt;UpdateItem&lt;/code&gt; and its update expression 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; — it emits the&lt;br&gt;
&lt;code&gt;SET&lt;/code&gt;/&lt;code&gt;ADD&lt;/code&gt; clause plus the attribute-name and value maps. In the app, DynoTable&lt;br&gt;
shows the resulting item after a staged write is committed, so you see the new state&lt;br&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%2F4nsobb3o2ldi3nj6yt5x.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%2F4nsobb3o2ldi3nj6yt5x.webp" alt="Reviewing an item's staged change in DynoTable — the old and new values before the update is committed."&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Pitfalls + next steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't &lt;code&gt;GetItem&lt;/code&gt;-then-write to read around a change&lt;/strong&gt; — it's a round trip and a race;
use &lt;code&gt;ReturnValues&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UPDATED_*&lt;/code&gt; returns only touched attributes&lt;/strong&gt; — if you need the whole item, use
&lt;code&gt;ALL_*&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PutItem&lt;/code&gt;/&lt;code&gt;DeleteItem&lt;/code&gt; can't return new values&lt;/strong&gt; — only &lt;code&gt;NONE&lt;/code&gt;/&lt;code&gt;ALL_OLD&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReturnValues&lt;/code&gt; is not a substitute for a condition&lt;/strong&gt; — to &lt;em&gt;guard&lt;/em&gt; a write, add a
&lt;a href="https://dynotable.com/learn/dynamodb-condition-expressions" rel="noopener noreferrer"&gt;condition expression&lt;/a&gt;; to &lt;em&gt;read back&lt;/em&gt; its
effect, use &lt;code&gt;ReturnValues&lt;/code&gt;. They compose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Related:&lt;/strong&gt; &lt;a href="https://dynotable.com/learn/dynamodb-update-expressions" rel="noopener noreferrer"&gt;update expressions&lt;/a&gt;,
&lt;a href="https://dynotable.com/learn/dynamodb-atomic-counters" rel="noopener noreferrer"&gt;atomic counters&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Want to make edits and see the before/after without scripting two calls?&lt;br&gt;
&lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;Download DynoTable&lt;/a&gt; and edit your items directly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Atomic counter with &lt;code&gt;UPDATED_NEW&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Inventory systems increment a &lt;code&gt;version&lt;/code&gt; or &lt;code&gt;stock&lt;/code&gt; field on every write. The&lt;br&gt;
pattern is one &lt;code&gt;UpdateItem&lt;/code&gt; with &lt;code&gt;ADD stock :inc&lt;/code&gt; and &lt;code&gt;ReturnValues:&lt;br&gt;
UPDATED_NEW&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UpdateItem  PK=SKU#8842
  UpdateExpression: ADD stock :one
  ExpressionAttributeValues: {":one": {"N": "1"}}
  ReturnValues: UPDATED_NEW
→ Attributes.stock.N == "41"   (was 40)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You receive only the changed attribute map, not the full item — ideal when the&lt;br&gt;
item is large but the caller needs the new counter. For audit trails that must&lt;br&gt;
capture every field before change, switch to &lt;code&gt;ALL_OLD&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The write still bills as an &lt;code&gt;UpdateItem&lt;/code&gt; on the item's size; &lt;code&gt;ReturnValues&lt;/code&gt; does&lt;br&gt;
not add a separate read charge — DynamoDB already loaded the item to apply the&lt;br&gt;
update.&lt;/p&gt;
&lt;h2&gt;
  
  
  Capacity note
&lt;/h2&gt;

&lt;p&gt;Returning attributes does not double the WCU cost of the write itself. You pay&lt;br&gt;
for the write based on the item size before and after the update per AWS&lt;br&gt;
rules, independent of how many attributes appear in the response payload.&lt;/p&gt;

&lt;p&gt;If you were tempted to &lt;code&gt;GetItem&lt;/code&gt; then &lt;code&gt;UpdateItem&lt;/code&gt; to log the old value, you&lt;br&gt;
paid for a read plus a write. &lt;code&gt;ReturnValues: ALL_OLD&lt;/code&gt; on the update removes the&lt;br&gt;
read entirely — on a 2 KB item at 500 updates per second that saves roughly&lt;br&gt;
250 eventually-consistent RCU per second.&lt;/p&gt;
&lt;h2&gt;
  
  
  Compose with condition expressions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ReturnValues&lt;/code&gt; and&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-condition-expressions" rel="noopener noreferrer"&gt;condition expressions&lt;/a&gt; compose on the&lt;br&gt;
same call. Example: increment &lt;code&gt;retryCount&lt;/code&gt; only while below a cap, and return the&lt;br&gt;
new count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ConditionExpression: retryCount &amp;lt; :max
UpdateExpression: ADD retryCount :one
ReturnValues: UPDATED_NEW
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the condition fails, DynamoDB returns &lt;code&gt;ConditionalCheckFailedException&lt;/code&gt; and&lt;br&gt;
no attribute payload — distinct from a successful update with an empty&lt;br&gt;
&lt;code&gt;UPDATED_NEW&lt;/code&gt; when nothing changed.&lt;/p&gt;

&lt;p&gt;Use the &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;expression builder&lt;/a&gt; to generate the&lt;br&gt;
&lt;code&gt;UpdateExpression&lt;/code&gt;, condition, and marshalled value maps together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision guide
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need…&lt;/th&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Works on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nothing back&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NONE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Put, Update, Delete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full item before overwrite/delete&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ALL_OLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Put, Update, Delete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only changed fields, before&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UPDATED_OLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Update&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full item after patch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ALL_NEW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Update&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only changed fields, after&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UPDATED_NEW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Update&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Deletes and puts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DeleteItem&lt;/code&gt; with &lt;code&gt;ReturnValues: ALL_OLD&lt;/code&gt; is how you implement "pop and return"&lt;br&gt;
semantics on a queue item — the deleted row comes back in &lt;code&gt;Attributes&lt;/code&gt;. There is&lt;br&gt;
no &lt;code&gt;ALL_NEW&lt;/code&gt; on delete because the item no longer exists.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PutItem&lt;/code&gt; with &lt;code&gt;ALL_OLD&lt;/code&gt; returns the previous item when you overwrite an existing&lt;br&gt;
key — useful for swap workflows. When the key did not exist, the response omits&lt;br&gt;
&lt;code&gt;Attributes&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify in DynoTable
&lt;/h2&gt;

&lt;p&gt;Stage an attribute change in the item editor: the review pane shows old and new&lt;br&gt;
values side by side before commit — the same information &lt;code&gt;UPDATED_OLD&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;UPDATED_NEW&lt;/code&gt; would return, without writing a script. After commit, copy the row&lt;br&gt;
as JSON for test fixtures via the grid's export actions.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>How DynamoDB Request Routing Works</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Tue, 11 Aug 2026 09:45:11 +0000</pubDate>
      <link>https://dev.to/dynotable/how-dynamodb-request-routing-works-579o</link>
      <guid>https://dev.to/dynotable/how-dynamodb-request-routing-works-579o</guid>
      <description>&lt;p&gt;Every read or write you send hits a fleet of stateless &lt;strong&gt;request routers&lt;/strong&gt; first.&lt;br&gt;
A router hashes your &lt;a href="https://dynotable.com/docs/dynamodb-glossary#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt;, maps the hash to the storage node that owns&lt;br&gt;
that key's data, and forwards the request there. That one hop is why a key lookup&lt;br&gt;
costs the same whether the table holds a thousand items or a billion.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does DynamoDB request routing work?
&lt;/h2&gt;

&lt;p&gt;DynamoDB routes every request through a stateless &lt;strong&gt;request router&lt;/strong&gt; fleet that hashes your &lt;a href="https://dynotable.com/docs/dynamodb-glossary#partition-key" rel="noopener noreferrer"&gt;partition key&lt;/a&gt;, maps the hash to the single storage node owning that partition, and forwards the read or write there. Routing is a pure function of the key's hash, so one lookup costs the same whether the table holds a thousand items or a billion.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The request router is the front door.&lt;/strong&gt; It's a stateless fleet that takes your
request, hashes the partition key, and routes it to the storage node holding that
partition — no scanning, no full-table knowledge needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The partition key decides everything.&lt;/strong&gt; Routing is a pure function of the
partition key's hash — the same key always routes to the partition that owns it, so
&lt;code&gt;GetItem&lt;/code&gt; is O(1), not O(table size).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One primary, two secondaries.&lt;/strong&gt; A write lands on the partition's primary node,
which acknowledges once a quorum (two of the three replicas) has persisted it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bad keys defeat the design.&lt;/strong&gt; A low-cardinality or &lt;a href="https://dynotable.com/docs/dynamodb-glossary#hot-partition" rel="noopener noreferrer"&gt;"hot" partition&lt;/a&gt; key funnels
traffic to one node — the routing is fine, your key is the problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start with the problem routing solves
&lt;/h2&gt;

&lt;p&gt;Coming from SQL, you picture a query planner: it reads statistics, picks an index,&lt;br&gt;
maybe scans. The cost scales with how much data it touches. That model doesn't fit&lt;br&gt;
a key-value store that has to answer in single-digit milliseconds at any size.&lt;/p&gt;

&lt;p&gt;DynamoDB's answer is to make a single-item lookup a &lt;strong&gt;direct address&lt;/strong&gt;, not a&lt;br&gt;
search. The partition key is the input to a hash function that computes &lt;em&gt;where the&lt;br&gt;
data physically lives&lt;/em&gt; — not a column you filter on. No statistics, no planner.&lt;/p&gt;

&lt;p&gt;That's the trade you accept when you move off relational thinking: you give up&lt;br&gt;
ad-hoc query flexibility and get constant-time addressing in return.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet the request router
&lt;/h2&gt;

&lt;p&gt;When a request arrives, it doesn't go straight to storage. It hits a &lt;strong&gt;request&lt;br&gt;
router&lt;/strong&gt; — a stateless, horizontally scaled fleet that fronts the whole service.&lt;br&gt;
(&lt;a href="https://www.usenix.org/conference/atc22/presentation/elhemali" rel="noopener noreferrer"&gt;The USENIX ATC '22 DynamoDB paper describes this request-router fleet.&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;The router does three things and holds no data of its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authenticates and authorizes&lt;/strong&gt; the request against IAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hashes the partition key&lt;/strong&gt; to find the partition that owns it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forwards&lt;/strong&gt; the request to the storage node for that partition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because routers are stateless, the service adds more of them under load. None of&lt;br&gt;
them is a bottleneck and none is a single point of failure — the same property the&lt;br&gt;
&lt;a href="https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf" rel="noopener noreferrer"&gt;2007 Amazon Dynamo paper&lt;/a&gt;&lt;br&gt;
built the original system around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow one read through the router
&lt;/h2&gt;

&lt;p&gt;Take a telemetry table for a drone fleet. Items are keyed by &lt;code&gt;DroneId&lt;/code&gt; (partition&lt;br&gt;
key) and &lt;code&gt;ReadingTs&lt;/code&gt; (sort key), with attributes like &lt;code&gt;BatteryPct&lt;/code&gt; and &lt;code&gt;AltitudeM&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You ask for one drone's readings from June 23:&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;PK = "DRONE#A19F"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SK begins_with "2026-06-23"&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;The diagram below traces the request top to bottom — read it as one downward flow.&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%2Fahcni7otmmox1djxsob4.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%2Fahcni7otmmox1djxsob4.png" alt="How DynamoDB Request Routing Works" width="800" height="1790"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The router hashes &lt;code&gt;DRONE#A19F&lt;/code&gt;, maps it to the partition that owns that key, and&lt;br&gt;
forwards the read to that partition's primary storage node, which returns the item.&lt;/p&gt;

&lt;p&gt;The hash points at &lt;em&gt;one&lt;/em&gt; partition out of however many the table&lt;br&gt;
has. The router never looks at other partitions, so adding drones — and partitions&lt;br&gt;
— doesn't slow this lookup down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know what a partition actually is
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;partition&lt;/strong&gt; is a unit of storage and throughput. Each one is capped (roughly&lt;br&gt;
10 GB and a fixed slice of read/write capacity), and DynamoDB splits a partition&lt;br&gt;
when it outgrows either limit. Every item with a given partition key starts on one&lt;br&gt;
partition; split-for-heat can later carve that collection by sort-key range (unless&lt;br&gt;
an LSI or a monotonic sort key pins it), which is what still makes a &lt;code&gt;Query&lt;/code&gt; over&lt;br&gt;
one partition key cheap.&lt;/p&gt;

&lt;p&gt;Each partition is replicated to three storage nodes spread across Availability&lt;br&gt;
Zones: one &lt;strong&gt;primary&lt;/strong&gt; and two &lt;strong&gt;secondaries&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;Node role&lt;/th&gt;
&lt;th&gt;Handles&lt;/th&gt;
&lt;th&gt;Consistency it can serve&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Primary&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;All writes; strongly-consistent reads&lt;/td&gt;
&lt;td&gt;Strong (sees its own latest write)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Secondary&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Eventually-consistent reads; failover&lt;/td&gt;
&lt;td&gt;Eventual (may lag the primary)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A write goes to the primary, which acknowledges the write once a quorum (two of the&lt;br&gt;
three replicas) has persisted it. A &lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary#strongly-consistent" rel="noopener noreferrer"&gt;strongly consistent&lt;/a&gt;&lt;/strong&gt; read is routed to the primary&lt;br&gt;
so it reflects the latest write. An &lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary#eventually-consistent" rel="noopener noreferrer"&gt;eventually consistent&lt;/a&gt;&lt;/strong&gt; read may be served&lt;br&gt;
by a secondary that hasn't caught up yet — half the cost, possibly stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Name the footgun: a hot partition key
&lt;/h2&gt;

&lt;p&gt;Routing is only as good as your partition key. The hash spreads keys evenly, so if&lt;br&gt;
your keys have &lt;strong&gt;high cardinality&lt;/strong&gt; and even traffic, load spreads across all&lt;br&gt;
nodes. Break either property and you get a &lt;strong&gt;hot partition&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Say you key that telemetry by &lt;code&gt;Region&lt;/code&gt; instead of &lt;code&gt;DroneId&lt;/code&gt;. Now every drone in&lt;br&gt;
&lt;code&gt;us-east-1&lt;/code&gt; shares one partition key — so their reads and writes hash to the same&lt;br&gt;
keyspace slot and pile onto one item collection. The router is doing its job&lt;br&gt;
perfectly; you've just funneled the whole fleet at a single partition's capacity.&lt;/p&gt;

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

&lt;p&gt;A hot key is a modeling bug, not a routing bug. The fix is a higher-cardinality&lt;br&gt;
partition key (or a write-sharding suffix), not more capacity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can't watch the router pick a node, but you &lt;em&gt;can&lt;/em&gt; design keys that route well.&lt;br&gt;
When you build a key condition in the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;Expression Builder&lt;/a&gt;, the partition key you put&lt;br&gt;
on the left of &lt;code&gt;PK = …&lt;/code&gt; is the exact value the router will hash — keeping that&lt;br&gt;
value high-cardinality is what keeps reads on separate nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this ties back to your access patterns
&lt;/h2&gt;

&lt;p&gt;Request routing is the mechanism that makes the &lt;a href="https://dynotable.com/learn/dynamodb-single-table-design" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;&lt;br&gt;
rules non-negotiable: you model around the partition key because the partition key&lt;br&gt;
&lt;em&gt;is&lt;/em&gt; the address. It's also why a &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan" rel="noopener noreferrer"&gt;&lt;code&gt;Query&lt;/code&gt; beats a &lt;code&gt;Scan&lt;/code&gt;&lt;/a&gt; —&lt;br&gt;
a &lt;code&gt;Query&lt;/code&gt; hits one partition through the router, while a &lt;code&gt;Scan&lt;/code&gt; walks every&lt;br&gt;
partition in sequence.&lt;/p&gt;

&lt;p&gt;Secondary indexes get their own partitions and their own routing: a&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi" rel="noopener noreferrer"&gt;GSI is routed by its own partition key&lt;/a&gt;, independent of the&lt;br&gt;
base table's, which is why a GSI can be hot even when the table isn't.&lt;/p&gt;

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

&lt;p&gt;Design keys that route to many nodes, not one. Sketch the &lt;code&gt;PK = …&lt;/code&gt; condition in the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder" rel="noopener noreferrer"&gt;Expression Builder&lt;/a&gt; to see exactly which value&lt;br&gt;
gets hashed, then &lt;a href="https://dynotable.com/download" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt; to run those queries against your&lt;br&gt;
own tables and see exactly what each key condition returns.&lt;/p&gt;

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