<?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: Maxime Lebastard</title>
    <description>The latest articles on DEV Community by Maxime Lebastard (@maxime).</description>
    <link>https://dev.to/maxime</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F350623%2Fecdab442-4fc9-49f6-8893-f8541566be33.jpeg</url>
      <title>DEV Community: Maxime Lebastard</title>
      <link>https://dev.to/maxime</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maxime"/>
    <language>en</language>
    <item>
      <title>Why DynamoDB Fails Most Real-World Apps</title>
      <dc:creator>Maxime Lebastard</dc:creator>
      <pubDate>Tue, 11 Nov 2025 23:58:07 +0000</pubDate>
      <link>https://dev.to/maxime/why-dynamodb-fails-most-real-world-apps-2i8p</link>
      <guid>https://dev.to/maxime/why-dynamodb-fails-most-real-world-apps-2i8p</guid>
      <description>&lt;p&gt;&lt;strong&gt;Brilliant KV at scale. Painful for most business queries.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I shipped a SaaS on DynamoDB. From start to scale during many years. Using DynamoDB as the primary store was one of my worst engineering calls. It feels great in week one: low cost, serverless, fast, safe, replicated, console out of the box. Then reality hits. Most business apps need flexible queries and evolving schemas. DynamoDB punishes both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it’s so tempting (and why that’s a trap)
&lt;/h2&gt;

&lt;p&gt;DynamoDB shines on slides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No servers to babysit&lt;/li&gt;
&lt;li&gt;Auto-scaling throughput&lt;/li&gt;
&lt;li&gt;Rock-solid durability, low-latency key lookups&lt;/li&gt;
&lt;li&gt;Backups, TTL, Streams, global tables&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And yes, AWS &lt;strong&gt;pushes it hard&lt;/strong&gt;. It shows up everywhere in Amplify tutorials, keynote demos, “serverless by default” narratives, and—let’s be blunt—&lt;strong&gt;RDS pricing optics make DynamoDB look cheap&lt;/strong&gt; at first glance. Don’t fall for it. Total cost of ownership for a real app with changing requirements tilts the other way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two core flaws that sink product teams
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Weak querying for real business needs
&lt;/h3&gt;

&lt;p&gt;Business apps rarely stop at “get by id.” They grow into multi-filter lists, admin dashboards, reports, exports, and “can we sort by X then Y?” asks.&lt;/p&gt;

&lt;p&gt;With DynamoDB:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You sort only within a partition.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.FilterExpression.html" rel="noopener noreferrer"&gt;Filters happen &lt;strong&gt;after&lt;/strong&gt; item selection.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Cross-attribute predicates need GSIs, denormalized views, or both.&lt;/li&gt;
&lt;li&gt;Every new dimension risks a backfill, a new GSI, or bespoke glue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What product asks for&lt;/strong&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="c1"&gt;-- Flexible list with three filters and stable multi-column sort&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&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;'paid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'FR'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add or remove a filter? Change the sort priority? Still trivial in SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DynamoDB reality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You’ll end up with a GSI on &lt;code&gt;(status, created_at)&lt;/code&gt; (maybe per tenant), another index or a composite key to slice by &lt;code&gt;country&lt;/code&gt;, and you still &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.KeyConditionExpressions.html" rel="noopener noreferrer"&gt;can’t do a global sort&lt;/a&gt; by &lt;code&gt;created_at, total, order_id&lt;/code&gt; across partitions. You fake it by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Querying multiple indexes&lt;/li&gt;
&lt;li&gt;Merging results in memory&lt;/li&gt;
&lt;li&gt;Resorting client-side&lt;/li&gt;
&lt;li&gt;Re-paginating manually&lt;/li&gt;
&lt;li&gt;Handling holes/dupes across pages
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Sketch: 3 filters (status, country, time window) + multi-sort emulation&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statuses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shipped&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;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;queries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;statuses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;IndexName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status-created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// PK=status, SK=created_at&lt;/span&gt;
  &lt;span class="na"&gt;KeyConditionExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#s = :status AND #t BETWEEN :since AND :now&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;FilterExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#c = :country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// post-filter, costs reads&lt;/span&gt;
  &lt;span class="na"&gt;ExpressionAttributeNames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#t&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;ExpressionAttributeValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:since&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;N&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:now&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;N&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;ScanIndexForward&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// created_at DESC&lt;/span&gt;
  &lt;span class="na"&gt;Limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="c1"&gt;// overfetch to emulate secondary sort&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;promise&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;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&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="c1"&gt;// Emulate ORDER BY created_at DESC, total DESC, order_id ASC&lt;/span&gt;
&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;      &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localeCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// manual pagination&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a nice workaround once. It becomes a maintenance swamp the moment someone says “also filter by payment_provider” or “sort by margin”.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Query vs Scan forces premature modeling and long-term rigidity
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-query-scan.html" rel="noopener noreferrer"&gt;This is stated quite hard&lt;/a&gt;, you should use Scan in production very carefully and prefer Query in hot paths - for cost and speed matters.&lt;/p&gt;

&lt;p&gt;DynamoDB makes you pick partition/sort keys and access patterns &lt;strong&gt;upfront&lt;/strong&gt;. But real products don’t freeze their questions on day one. You end up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Over-engineering single-table designs before you have traction&lt;/li&gt;
&lt;li&gt;Backfilling GSIs when requirements change&lt;/li&gt;
&lt;li&gt;Fighting hot partitions and throughput tuning&lt;/li&gt;
&lt;li&gt;Paying in complexity every time you add a filter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In an RDBMS, you add an index and move on. In DynamoDB, you plan a migration, tweak streams, write backfills, and hope you didn’t miss a denormalized projection.&lt;/p&gt;

&lt;h2&gt;
  
  
  About AWS “workarounds” and their costs
&lt;/h2&gt;

&lt;p&gt;You’ll hear: “Keep DynamoDB for writes, then sync to something query-friendly.”&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenSearch sync:&lt;/strong&gt; $200-$1000 monthly cluster cost, index pipelines, mapping drift, cluster sizing, reindex pain, a new skillset to learn. Also another thing to break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RDS/Postgres sync:&lt;/strong&gt; At that point, why not just use Postgres first? Dual-write or stream-ingest adds failure modes and ops overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Athena/Glue/S3 sync:&lt;/strong&gt; Fine for batch analytics, not product queries. Latency, freshness, partitioning strategy, and scan-based pricing complicate everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Honnestly yes, these can work. They also eat the operational savings DynamoDB promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The single-table design mess
&lt;/h2&gt;

&lt;p&gt;During my journey with ddb, I found this nested-pattern: &lt;a href="https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/" rel="noopener noreferrer"&gt;the single-table design&lt;/a&gt;. Intended to simplify the query model and get more cost control.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/" rel="noopener noreferrer"&gt;The same author&lt;/a&gt; says multi-table can be the better fit in many apps — don’t cargo-cult single-table.&lt;/p&gt;

&lt;p&gt;The core is as simple as “put many entity types in one table and prefix the PK with &lt;code&gt;User#123&lt;/code&gt;, &lt;code&gt;Product#456&lt;/code&gt;, etc.” pattern (single-table design) is pitched as how you “model relationships in DynamoDB.”&lt;/p&gt;

&lt;p&gt;I would not recommand it and thankfully it has never reached my prod. Here’s what gets glossed over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streams:&lt;/strong&gt; Mixed firehose. Every consumer re-implements routing + type logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring:&lt;/strong&gt; Metrics blur across entity types. Hot keys and throttles are harder to triage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PITR/Backups/Restore:&lt;/strong&gt; You can’t easily restore “just Orders for tenant X.” It’s all intertwined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety:&lt;/strong&gt; One bad writer can pollute unrelated entities. Blast radius grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your model evolves:&lt;/strong&gt; so optimising merge operations by hard-linking concepts again doesn't sounds long-term to my ears.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Advantages? Fewer tables and slightly simpler IAM. Not worth the production pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DynamoDB Streams can go wrong
&lt;/h2&gt;

&lt;p&gt;Streams &lt;strong&gt;look&lt;/strong&gt; like a clean event bus to fan out data (CDC) to internal/external sinks and even trigger side-effects (emails, indexing).&lt;/p&gt;

&lt;p&gt;In practice, they may bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hidden, unpredictable control flow&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html" rel="noopener noreferrer"&gt;Non-deterministic replays/restores&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Risky coupling of storage and compute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On &lt;strong&gt;table restores&lt;/strong&gt;, item order shifts. Your sinks won’t see the same sequence—hello divergence in OpenSearch or a relational mirror. If the stream fires &lt;strong&gt;business side-effects&lt;/strong&gt; (e.g., email on order creation), restores can wreak havoc: duplicates, email storms, false alerts.&lt;/p&gt;

&lt;p&gt;And yes, some stream deliveries &lt;strong&gt;fail&lt;/strong&gt;. Now you own desync reconciliation, backfills, and a new incident surface.&lt;/p&gt;

&lt;p&gt;Bottom line: use streams for &lt;strong&gt;CDC&lt;/strong&gt; and &lt;strong&gt;replayable&lt;/strong&gt; side-effects only. Keep critical workflows in explicit services/queues you can control and re-run safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills, hiring, and the people cost
&lt;/h2&gt;

&lt;p&gt;Be honest about ramp-up and recruiting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DynamoDB modeling is &lt;strong&gt;non-standard&lt;/strong&gt; and &lt;strong&gt;hard&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Deep experience is rare and expensive.&lt;/li&gt;
&lt;li&gt;Onboarding takes longer; design reviews are slower.&lt;/li&gt;
&lt;li&gt;Postgres skills are ubiquitous, cheaper, and transferable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your org pays this tax every time the team changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reality check: errors, reworks, and painful backfills
&lt;/h2&gt;

&lt;p&gt;Here’s what “small change” really means in DynamoDB.&lt;/p&gt;

&lt;h3&gt;
  
  
  “We need to move/rename one attribute” (12 million items)
&lt;/h3&gt;

&lt;p&gt;Common scenario: you mis-modeled a field, or product changes semantics. In SQL, you run a migration and maybe a background job. In DynamoDB, you’re looking at a full backfill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it takes&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Design:&lt;/strong&gt; choose between dual-writing, on-read fix-ups, or hard backfill. Plan stream consumers, idempotency, and cutover. Expect days of design/review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; writer updates, reader compatibility layer, backfill Lambda/Batch job, metrics + alarms, retry semantics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run:&lt;/strong&gt; backfill across 12M items. Reads + writes per item. Throttle management. Monitoring. Potential hot partitions. Expect hours to days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean-up:&lt;/strong&gt; remove old paths, drop temporary GSIs, tidy configs. More PRs, more risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; 12m PutItems operations &lt;a href="https://aws.amazon.com/dynamodb/pricing/on-demand/" rel="noopener noreferrer"&gt;is $15-$30&lt;/a&gt;, which is a lot compared to the promise of the product&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time:&lt;/strong&gt; Expect days to weeks as each item has to be Put one by one (&lt;em&gt;BatchItem is just a sugar wrapper on top of PutItem, sorry&lt;/em&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why teams punt instead&lt;/strong&gt;&lt;br&gt;
Because the math is ugly. A backfill touches &lt;strong&gt;every item&lt;/strong&gt;. You pay request units, you risk throttles, and you burn engineering time. Pragmatic teams often:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Patch at read time (“if newAttr missing, compute from oldAttr”)&lt;/li&gt;
&lt;li&gt;Dual-store for a while&lt;/li&gt;
&lt;li&gt;Leave both fields forever “just in case”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;But that’s debt&lt;/strong&gt;. It leaks into code paths, tests, analytics, exports, and docs. Six months later nobody remembers which field is truthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a realistic plan looks like (order of magnitude)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch size 500–1,000 items&lt;/li&gt;
&lt;li&gt;Adaptive throttling with exponential backoff&lt;/li&gt;
&lt;li&gt;Idempotent upserts&lt;/li&gt;
&lt;li&gt;Progress checkpoints per partition to allow restarts&lt;/li&gt;
&lt;li&gt;Dual-read in the app during the migration window&lt;/li&gt;
&lt;li&gt;Feature flag for cutover + shadow reads to verify parity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You’ll spend more time building the migration machinery than the feature that prompted it.&lt;/p&gt;

&lt;h3&gt;
  
  
  “This migration will run for 9 hours—how do we stay up?”
&lt;/h3&gt;

&lt;p&gt;Long backfills are normal. Operating during them is the real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capacity planning:&lt;/strong&gt; provision enough throughput to avoid starvation, but don’t starve the app. If on-demand, you still need guardrails to avoid request spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write throttling:&lt;/strong&gt; use token buckets per partition to prevent hot keys; respect adaptive capacity but don’t assume it saves you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read/write isolation:&lt;/strong&gt; do &lt;strong&gt;read-your-writes&lt;/strong&gt; checks inside the job to avoid flapping values; consider conditional writes to prevent races.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency:&lt;/strong&gt; every batch must be replayable safely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial failures:&lt;/strong&gt; checkpoint aggressively; never rely on “last evaluated key” alone; persist progress externally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cutover:&lt;/strong&gt; run shadow reads comparing old vs new for a sample; flip behind a flag; keep the rollback path warm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability:&lt;/strong&gt; emit per-partition metrics, success/error counts, throttle counts, and job liveness. Alert on stall, not just failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is platform engineering work you wouldn’t need with an RDBMS for a simple attribute move.&lt;/p&gt;

&lt;h2&gt;
  
  
  When DynamoDB is the right call
&lt;/h2&gt;

&lt;p&gt;Use it confidently when the workload is truly key-value at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IoT ingestion keyed by device/time&lt;/li&gt;
&lt;li&gt;Caching and session stores&lt;/li&gt;
&lt;li&gt;Feature flags and configuration&lt;/li&gt;
&lt;li&gt;Idempotency/dedupe tables&lt;/li&gt;
&lt;li&gt;Offloading cold, simple entities from your RDBMS for cost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;In those lanes, DynamoDB is best-in-class.&lt;/strong&gt; The trap is to be misled by demos and marketing and using it for the wrong need.&lt;/p&gt;

&lt;h2&gt;
  
  
  A saner default for most apps
&lt;/h2&gt;

&lt;p&gt;Start with Postgres on managed RDS. &lt;/p&gt;

&lt;p&gt;It might look pricier than DynamoDB in month one, but your team already speaks SQL, every framework is happy, and the ops overhead is predictable. &lt;/p&gt;

&lt;p&gt;Over a year, that familiarity and flexibility usually make it cheaper—and you’ll ship faster, adapt safely, and answer ad-hoc questions without gymnastics. &lt;/p&gt;

&lt;p&gt;Once the product settles, peel off true key-value hotspots to DynamoDB if it actually helps.&lt;/p&gt;

&lt;h2&gt;
  
  
  A word on DSQL
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/fr/rds/aurora/dsql/" rel="noopener noreferrer"&gt;Aurora DSQL&lt;/a&gt;, in preview this year, aims to blend DynamoDB’s strengths (cost, operational ease, scale, backups, security) with Postgres ergonomics (relational data, proper sorting, filtering, joins to a degree). On paper, it’s the best of both worlds.&lt;/p&gt;

&lt;p&gt;Reality check: as of today (11/2025) &lt;a href="https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-postgresql-compatibility-unsupported-features.html" rel="noopener noreferrer"&gt;it comes with sharp edges&lt;/a&gt;—no foreign keys, no JSON columns, no auto-increment sequences, and other early-stage gaps. I’m optimistic about the direction, but cautious: until these limits close, DSQL could become &lt;strong&gt;another&lt;/strong&gt; trap for production apps that need full SQL guarantees.&lt;/p&gt;

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