<?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: Camma Smith</title>
    <description>The latest articles on DEV Community by Camma Smith (@camma_smith_1).</description>
    <link>https://dev.to/camma_smith_1</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%2F3870247%2F14a8d29c-0ab1-46cb-800f-59f0836a2d5b.png</url>
      <title>DEV Community: Camma Smith</title>
      <link>https://dev.to/camma_smith_1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/camma_smith_1"/>
    <language>en</language>
    <item>
      <title>Buy vs. Build: The Answer Has Changed</title>
      <dc:creator>Camma Smith</dc:creator>
      <pubDate>Mon, 22 Jun 2026 14:30:52 +0000</pubDate>
      <link>https://dev.to/camma_smith_1/buy-vs-build-the-answer-has-changed-13me</link>
      <guid>https://dev.to/camma_smith_1/buy-vs-build-the-answer-has-changed-13me</guid>
      <description>&lt;p&gt;Every company that uses software eventually faces a version of the same question: should we pay a vendor for this, or build it ourselves? For most of commercial software history, the answer leaned heavily toward buying. That answer is shifting, and the implications are uncomfortable for vendors and buyers alike.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the question came from
&lt;/h2&gt;

&lt;p&gt;Before packaged software existed, every company that needed software built it. Payroll systems, inventory tracking, order management: all custom, all internal. Then, in the 1970s and 80s, the commercial software industry emerged. Companies like SAP and Oracle began selling enterprise software that handled entire business functions in one system.&lt;/p&gt;

&lt;p&gt;SAP's ERP is the canonical example of why buying made sense. ERP, or Enterprise Resource Planning, is software that unifies a company's core operations: accounting, HR, procurement, inventory, manufacturing, supply chain. It is enormously complex. SAP spent years building their system, handling edge cases across dozens of industries and thousands of business scenarios. No individual company could justify that investment. But SAP could, because the cost spread across every customer they sold to.&lt;/p&gt;

&lt;p&gt;The economics were clear: a vendor could invest far more in a specialized product than any single buyer could, and every buyer got access to the depth of that investment for a fraction of the cost. The conventional wisdom that emerged from this era was intuitive: buy for commodity functions, build only for competitive advantage. Don't build your own payroll system. Do build your recommendation engine if recommendations are your product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case for buy vs. build
&lt;/h2&gt;

&lt;p&gt;The arguments for buying were strong. Vendors specialized deeply. They had teams dedicated to a single product, absorbing requirements and edge cases that no individual company would think to handle. Buying meant faster time to value, a predictable cost structure, and someone else responsible for maintenance. It also usually meant a breadth of features that internal teams could rarely match.&lt;/p&gt;

&lt;p&gt;Building had real advantages too. Full control over the product, no vendor lock-in, the ability to customize to exact requirements, and ownership of the underlying logic and data. But those advantages came at a cost that was easy to underestimate: not just the initial build, but ongoing maintenance. Software doesn't sit still. Requirements change, infrastructure evolves, and bugs surface in production. The team that built the thing becomes responsible for it indefinitely. For anything that wasn't central to the business, that tradeoff rarely made sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the following decades
&lt;/h2&gt;

&lt;p&gt;The 2000s introduced SaaS and made buying even easier. No installation, no on-premise infrastructure, subscription pricing, and tools that teams could adopt in days rather than months. The barrier to buying dropped further. Open source offered a middle path: free to use, but still requiring someone to run it, upgrade it, and handle whatever it didn't do out of the box. For most companies, the commercial option remained the practical choice.&lt;/p&gt;

&lt;p&gt;Throughout this period, the underlying calculus stayed roughly constant. The expertise gap between a vendor and an internal team was wide. Vendors had specialists. Internal teams had generalists stretched across many priorities. Building anything serious required experienced developers, long timelines, and a maintenance commitment that compounded over time. The wisdom held: buy the commodity, build the moat.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI changes everything
&lt;/h2&gt;

&lt;p&gt;The cost of building has dropped substantially over the last few years, and it continues to drop. AI helps with scaffolding, boilerplate, integrations, debugging, and iteration. A developer who isn't a specialist in a domain can now produce credible software in that domain, because AI fills the knowledge gaps that used to require years of experience. Tasks that once took a team months can take a single developer weeks.&lt;/p&gt;

&lt;p&gt;Things that once lived firmly in the "buy" column have crossed into "build." The long tail of software that companies bought because building was too expensive is now buildable, by smaller teams, faster, with less expertise required. Internal tools, custom integrations, specialized data pipelines: these were always better built than bought in principle, but the cost made buying the only practical option. That's no longer the case. The gap has closed, and it will keep closing. This doesn't mean building is free or easy. The threshold has moved, not disappeared.&lt;/p&gt;

&lt;p&gt;The principle hasn't changed. Buy for commodity, build for advantage still holds. What AI has changed is where that line falls. More things that used to be "buy because you can't afford to build it" are becoming "build because you can now afford to." And the pace of that shift is accelerating.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;This is where the implications get uncomfortable.&lt;/p&gt;

&lt;p&gt;If building is getting easier every year, the natural question for any buyer is: why pay for something I could build myself? A SaaS product that solves a narrow, well-defined problem is increasingly at risk of being replaced by something a team assembled in a few days with AI assistance. The economics that once made buying obvious are changing.&lt;/p&gt;

&lt;p&gt;But there is a cost that the initial build effort consistently hides: ownership. Software isn't done when it ships. It needs to be updated when dependencies change, extended when requirements grow, debugged when production breaks, and understood by whoever inherits it. That burden accumulates. A tool built in a weekend can quietly become a recurring weekend every month. The question isn't how much effort it takes to build something. It's how much effort it takes to own it over time.&lt;/p&gt;

&lt;p&gt;This is the real tension in the buy vs. build decision today. For a buyer: how much can we build for ourselves before the management and maintenance burden exceeds what a vendor would charge? The answer isn't the same for every team. A small team with limited engineering capacity has a lower threshold than a platform team with dedicated infrastructure engineers. A tool that rarely changes is cheaper to maintain than one that needs constant updates to stay current.&lt;/p&gt;

&lt;p&gt;For a vendor, the question is harder. How do you build a product that a customer will value more than something they could build themselves? Price used to answer that question. If building cost ten times as much as buying, the math was simple. Now, for narrower tools, the gap has closed. A team can often replicate basic functionality faster than a sales cycle closes.&lt;/p&gt;

&lt;p&gt;What remains is depth. A mature product has years of production exposure. It has encountered the edge cases, handled the bad inputs, survived the scale spikes, and integrated with everything the customer was already running. That's real value, and it's hard to reproduce quickly. But it's also invisible in a demo. Vendors who have been selling on surface-level features are the most exposed. Vendors who have been solving the hard problems, the ones that only surface after months of real-world use, are the ones whose products remain difficult to replicate.&lt;/p&gt;

&lt;p&gt;The line between buy and build is not fixed. It is moving, and it is moving toward build. The question both sides are now trying to answer is the same one it has always been: is this worth doing yourself, or is someone else already doing it better? The difference is that "better" is a higher bar than it used to be.&lt;/p&gt;

&lt;p&gt;Nobody is going to pay for software they can build with minimal effort. The products worth paying for are the ones where the effort isn't minimal, even with AI. Finding and communicating that clearly is, increasingly, what the buy vs. build question is really about.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwaredevelopment</category>
      <category>saas</category>
      <category>startup</category>
    </item>
    <item>
      <title>Running Real SQL on DynamoDB: How It Actually Works</title>
      <dc:creator>Camma Smith</dc:creator>
      <pubDate>Mon, 08 Jun 2026 14:49:19 +0000</pubDate>
      <link>https://dev.to/camma_smith_1/running-real-sql-on-dynamodb-how-it-actually-works-32lg</link>
      <guid>https://dev.to/camma_smith_1/running-real-sql-on-dynamodb-how-it-actually-works-32lg</guid>
      <description>&lt;p&gt;DynamoDB is good at what it was designed for: fast, predictable reads and writes against known access patterns. It is not designed for ad-hoc queries. If you need to join two tables, aggregate by a column, or ask a question your schema didn't anticipate, the answer is application code: SDK calls, manual pagination, in-memory joins. A simple question might take 50 lines. A complex one takes more, and the logic ends up scattered across your codebase. As you can imagine, that gets frustrating very quickly. That's where DynamoSQL comes in.&lt;/p&gt;

&lt;p&gt;DynamoSQL is a read-only SQL engine built on top of DynamoDB that closes that gap. You write standard ANSI SQL SELECT statements against your existing tables. On the surface that sounds simple. Under the hood, it involves a parser, a query planner, an optimizer that inspects your table's index structure, and a batched execution engine that coordinates multiple DynamoDB API calls to produce a result that DynamoDB couldn't produce on its own.&lt;/p&gt;

&lt;p&gt;This article walks through all of it: how DynamoDB's access model works, why it makes certain queries hard, and exactly what DynamoSQL does to close that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How DynamoDB stores and retrieves data
&lt;/h2&gt;

&lt;p&gt;DynamoDB is a key-value and document store. Every item you store in a DynamoDB table has a partition key, which is the attribute DynamoDB uses to decide which physical server holds that item. If your table also has a sort key, items with the same partition key are stored together and sorted by that second attribute.&lt;/p&gt;

&lt;p&gt;This design is intentional. By routing all reads and writes for a given partition key to a single location, DynamoDB can guarantee single-digit millisecond latency at any scale. There's no coordination between servers, no lock contention, no complex query planning. You give it a key, it gives you the data.&lt;/p&gt;

&lt;p&gt;The tradeoff is that you have to tell DynamoDB your partition key to get any data efficiently. Without it, you're left with a Scan: DynamoDB reads every item in the table and filters the results afterward. On a large table, a Scan that returns 10 rows might read millions of items to find them. The cost, both in time and in read capacity, scales with the amount of data read, not the amount returned.&lt;/p&gt;

&lt;p&gt;To support multiple access patterns without resorting to scans, DynamoDB offers Global Secondary Indexes (GSIs). A GSI is an additional index on a different partition key and optional sort key. DynamoDB maintains a copy of your data indexed by those keys, letting you query on attributes that aren't your table's primary key.&lt;/p&gt;

&lt;p&gt;The catch is that you have to define your GSIs before you need them, and they need to match the exact access patterns you anticipate. If someone asks a question you didn't foresee at design time, you either build a new GSI and backfill, or you scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing tables for access patterns
&lt;/h2&gt;

&lt;p&gt;The standard advice for DynamoDB table design is to work backward from your queries. Before you write a single item, you list every query your application needs to support, then design a partition and sort key structure that covers all of them.&lt;/p&gt;

&lt;p&gt;To make this concrete, say you're building an e-commerce platform. You might have two tables:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orders table&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Partition key: &lt;code&gt;customer_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Sort key: &lt;code&gt;order_date&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Attributes: &lt;code&gt;order_id&lt;/code&gt;, &lt;code&gt;total&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;region&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Customers table&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Partition key: &lt;code&gt;customer_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Attributes: &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With this design, the query "get all orders for customer 123" is fast and cheap: DynamoDB queries the Orders partition for &lt;code&gt;customer_id = "123"&lt;/code&gt; and returns results sorted by date. That access pattern is well-supported.&lt;/p&gt;

&lt;p&gt;But the query "how much revenue did we generate per state last week" is a different story. That requires:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reading all orders from the last week (there's no &lt;code&gt;order_date&lt;/code&gt; GSI, so this is a scan of the entire Orders table)&lt;/li&gt;
&lt;li&gt;Looking up the state for each customer (a separate read against the Customers table for each order)&lt;/li&gt;
&lt;li&gt;Aggregating the totals by state&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;DynamoDB can't do any of that natively. There's no JOIN between Orders and Customers. There's no GROUP BY. There's no SUM. The application has to do all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the native API looks like
&lt;/h2&gt;

&lt;p&gt;Here's what that query looks like in JavaScript using the AWS SDK 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="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DynamoDBDocumentClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ScanCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BatchGetCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;docClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DynamoDBDocumentClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oneWeekAgo&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;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;oneWeekAgo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oneWeekAgo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&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;startDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oneWeekAgo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Step 1: Scan all orders from the last week&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;docClient&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;ScanCommand&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;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;order_date &amp;gt;= :start&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;:start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startDate&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;lastKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Items&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]));&lt;/span&gt;
    &lt;span class="nx"&gt;lastKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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;lastKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2: Batch-fetch the customer record for each order&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customerIds&lt;/span&gt; &lt;span class="o"&gt;=&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;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orders&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;o&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_id&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;customerKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customerIds&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;id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&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;customerMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;customerKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;100&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;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customerKeys&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="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;docClient&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;BatchGetCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;RequestItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;Customers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="k"&gt;for &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;customer&lt;/span&gt; &lt;span class="k"&gt;of &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Responses&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;Customers&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;customerMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Step 3: Join and aggregate in application code&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revenueByState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="k"&gt;for &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;order&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;orders&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;customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customerMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;revenueByState&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&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;revenueByState&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;order&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Step 4: Sort results&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;revenueByState&lt;/span&gt;&lt;span class="p"&gt;)&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;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;revenue&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="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;revenue&lt;/span&gt; &lt;span class="p"&gt;}))&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="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revenue&lt;/span&gt; &lt;span class="o"&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;revenue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's about 50 lines of code. It performs a full table scan of Orders (reading every item regardless of how many match), makes a separate BatchGet call to Customers, handles pagination manually, and aggregates the result in application code. The cost of the scan grows with your table size, not with your result size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same query in DynamoSQL
&lt;/h2&gt;

&lt;p&gt;This is where DynamoSQL comes in. Instead of writing and maintaining that 50-line SDK routine, you hand the problem to DynamoSQL and let it figure out how to fetch the data efficiently. Here's the same query written in SQL:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;myschema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;myschema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. DynamoSQL handles everything the 50-line version does manually, and it does it more efficiently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What DynamoSQL does with that query
&lt;/h2&gt;

&lt;p&gt;When you send this SQL to DynamoSQL, it goes through five stages: parsing, planning, optimizing, fetching, and aggregating.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Parsing
&lt;/h3&gt;

&lt;p&gt;The SQL string is parsed into an Abstract Syntax Tree (AST): a structured representation of what the query is asking for. The parser identifies the SELECT columns, the FROM clause with its JOIN condition, the WHERE filter, the GROUP BY clause, and the ORDER BY. At this stage it is just structure. No data has been touched.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Planning
&lt;/h3&gt;

&lt;p&gt;The AST is converted into an execution plan, which is a tree of iterator nodes. Each node represents a step in the query: scan a table, apply a filter, execute a join, group and aggregate rows, sort. If you were to draw this plan out, it would look like a tree of operations, where each node feeds rows up to the node above it.&lt;/p&gt;

&lt;p&gt;At this point the plan is correct but not yet optimized. A naive execution would scan the entire Orders table, scan the entire Customers table, perform a nested-loop join, then filter, group, and sort.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Optimizing
&lt;/h3&gt;

&lt;p&gt;This is where DynamoSQL earns its value. The optimizer inspects your DynamoDB table metadata: the partition keys, sort keys, and GSI definitions, and rewrites the plan to use the most efficient access path available.&lt;/p&gt;

&lt;p&gt;For the query above, the optimizer reasons through the following:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the Orders table:&lt;/strong&gt; the WHERE clause filters on &lt;code&gt;order_date&lt;/code&gt;. The Orders table has a sort key of &lt;code&gt;order_date&lt;/code&gt;. If the optimizer also has a partition key value to anchor the query, it can use DynamoDB's Query API directly. But here there's no specific &lt;code&gt;customer_id&lt;/code&gt; filter, so no partition key is available for Orders. The optimizer notes this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the JOIN on &lt;code&gt;customer_id&lt;/code&gt;:&lt;/strong&gt; the Customers table has &lt;code&gt;customer_id&lt;/code&gt; as its partition key. This is important. Once the optimizer knows which &lt;code&gt;customer_id&lt;/code&gt; values come back from Orders, it can fetch matching Customers rows using BatchGet, DynamoDB's API for fetching up to 100 items by exact key in a single request. This is far cheaper than scanning Customers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Execution order:&lt;/strong&gt; the optimizer determines that Orders should be fetched first (as a scan, since there's no key filter), and then for each batch of Orders rows, it fires BatchGet requests against Customers using the &lt;code&gt;customer_id&lt;/code&gt; values from those rows. This avoids scanning Customers entirely.&lt;/p&gt;

&lt;p&gt;If a matching GSI had existed on Orders (for example, a GSI with &lt;code&gt;region&lt;/code&gt; as the partition key and &lt;code&gt;order_date&lt;/code&gt; as the sort key), the optimizer would have used a Query against that GSI instead of a scan. The scan is always the fallback of last resort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on the scan in this example:&lt;/strong&gt; the scan on Orders is unavoidable given the table design above. The Orders table has &lt;code&gt;customer_id&lt;/code&gt; as its partition key and &lt;code&gt;order_date&lt;/code&gt; as its sort key. Because DynamoDB's Query API always requires a partition key value, a date range filter alone can't drive a Query. You'd need to also specify a &lt;code&gt;customer_id&lt;/code&gt;, which we don't have. The sort key on &lt;code&gt;order_date&lt;/code&gt; only helps once the partition is already known.&lt;/p&gt;

&lt;p&gt;This is a real limitation of the table design, not a limitation of DynamoSQL. If you wanted to make date range queries efficient, you'd add a GSI. One common pattern is to use a synthetic partition key, such as a fixed value like &lt;code&gt;"orders"&lt;/code&gt; or a date bucket like &lt;code&gt;"2026-06"&lt;/code&gt;, used as the GSI partition key, with &lt;code&gt;order_date&lt;/code&gt; as the GSI sort key. DynamoSQL would then detect that GSI during optimization and use a Query instead of a Scan, drastically reducing read costs.&lt;/p&gt;

&lt;p&gt;The key point is that DynamoSQL's optimizer works with whatever indexes your table already has. It doesn't require you to redesign your schema. But if your schema has indexes that can serve a query efficiently, it will find and use them automatically. The more indexes you have that match your query patterns, the less DynamoSQL has to scan.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Fetching
&lt;/h3&gt;

&lt;p&gt;Execution runs through a chain of batch collectors. The engine works in batches of 100 rows at a time.&lt;/p&gt;

&lt;p&gt;The first collector fetches a batch of rows from Orders via Scan. It passes those rows to the next collector, which extracts the &lt;code&gt;customer_id&lt;/code&gt; values and fires a BatchGet against Customers, fetching up to 100 customer records in a single DynamoDB API call. The two sets of rows are then joined on &lt;code&gt;customer_id&lt;/code&gt; by matching the keys.&lt;/p&gt;

&lt;p&gt;This continues in a loop: fetch 100 Orders rows, BatchGet the corresponding Customers rows, join them, pass the joined rows up the chain. No full scan of Customers ever happens. Each customer lookup is a direct key-based fetch.&lt;/p&gt;

&lt;p&gt;The pagination that the 50-line version handles manually is managed internally. If Orders has 50,000 items, the engine pages through them in batches of 100, firing BatchGet calls against Customers for each batch, until all matching rows have been retrieved.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Aggregating
&lt;/h3&gt;

&lt;p&gt;Once joined rows flow up through the execution tree, the GROUP BY node collects them by state, the SUM aggregate accumulates the totals, and the ORDER BY node sorts the final result. These operations run in the DynamoSQL execution engine, not in DynamoDB. DynamoDB doesn't support them natively.&lt;/p&gt;

&lt;p&gt;The result comes back as a JSON array of rows, exactly what you'd get from an SQL database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why avoiding a full scan matters
&lt;/h2&gt;

&lt;p&gt;DynamoDB bills for read capacity proportional to the amount of data read, not returned. A scan that reads 1 million items to return 500 costs the same as reading 1 million items with no filter at all.&lt;/p&gt;

&lt;p&gt;DynamoSQL avoids scans wherever possible by inspecting your index metadata before execution. If your WHERE clause matches a GSI partition key, it uses a Query. If a JOIN condition ties to another table's primary key, it uses BatchGet. A scan only happens when no other path is available, and when it does happen, the engine still processes results in batches rather than loading everything into memory at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;The 50-line SDK example and the 6-line SQL query produce the same result. The difference is that DynamoSQL's execution is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cheaper&lt;/strong&gt;: BatchGet is a direct key lookup. Scanning Customers never happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster&lt;/strong&gt;: BatchGet requests fire in batches as Orders rows arrive, rather than waiting for all Orders to load first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simpler&lt;/strong&gt;: The pagination, batching, joining, and aggregation logic is inside the engine, not scattered through your application code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SQL you write tells DynamoSQL what you want. The optimizer and execution engine figure out the most efficient way to get it from DynamoDB.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>sql</category>
      <category>dynamodb</category>
      <category>aws</category>
    </item>
    <item>
      <title>Why LLM Agents Still Can't Query NoSQL Databases</title>
      <dc:creator>Camma Smith</dc:creator>
      <pubDate>Thu, 04 Jun 2026 15:01:07 +0000</pubDate>
      <link>https://dev.to/camma_smith_1/why-llm-agents-still-cant-query-nosql-databases-38ja</link>
      <guid>https://dev.to/camma_smith_1/why-llm-agents-still-cant-query-nosql-databases-38ja</guid>
      <description>&lt;p&gt;When it comes to SQL databases, LLMs are great at writing SQL. It's precise, expressive, and unambiguous. LLMs write it well. Connect an MCP server to Postgres and the agent can write queries directly and efficiently. It's a lot harder for agents to work with NoSQL databases, and given how much production data lives in them, I'm surprised there isn't more discussion about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SQL works so well for LLMs
&lt;/h2&gt;

&lt;p&gt;The core reason SQL is a natural interface for language models is specificity. A SQL query says exactly what data you want, in exactly what shape, with exactly what conditions. There's no ambiguity for the model to navigate.&lt;/p&gt;

&lt;p&gt;SQL is also one of the most well-represented languages in LLM training data. Decades of documentation, Stack Overflow answers, textbooks, and open source code have produced an enormous corpus for models to learn from. The syntax is consistent. The semantics are well-defined. When an LLM writes SQL, it's drawing on a deep, reliable foundation.&lt;/p&gt;

&lt;p&gt;It also maps cleanly to tool calls. An agent calls run_sql with a query string and gets back rows. The interface is simple, inputs and outputs are typed, and errors are catchable. It's a well-defined contract between the agent and the data layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NoSQL problem
&lt;/h2&gt;

&lt;p&gt;The same isn't true for NoSQL, and the root cause isn't just syntax. It's that there's no shared interface. Each major NoSQL database has its own query model, its own access patterns, and its own constraints. For an LLM to query any of them effectively, it needs to understand not just the syntax but the underlying data model.&lt;/p&gt;

&lt;p&gt;MongoDB uses a document query syntax built around JSON filters and aggregation pipelines. DynamoDB uses an API-style expression language built around partition keys and index access patterns. Cassandra has CQL, which looks like SQL but has strict limitations on what queries are allowed based on how tables are partitioned. Redis exposes commands rather than queries. Neo4j uses Cypher, a graph query language with its own pattern-matching syntax.&lt;/p&gt;

&lt;p&gt;These aren't variations on the same problem. Each one requires the agent to internalize a different mental model for how data is stored and retrieved. There's no training data equivalent to decades of SQL documentation to draw from. And because developers' attention is split across all of them, the tooling and ecosystem around each is thinner than what SQL databases enjoy.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete DynamoDB example
&lt;/h2&gt;

&lt;p&gt;Take DynamoDB specifically. It requires a partition key for efficient queries. Without one, you're doing a full table scan, which reads every item in the table and applies filters afterward. It doesn't support JOINs, native aggregates, or GROUP BY.&lt;/p&gt;

&lt;p&gt;Say an agent needs to answer: "Find how many orders were placed by region this week."&lt;/p&gt;

&lt;p&gt;In SQL, that's straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;order_count&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;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;region&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;order_count&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In DynamoDB, the agent can't write that query. It has to write code:&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;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan&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;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;created_at &amp;gt;= :start&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;:start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;for &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;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&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;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;lastKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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;lastKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is more code, harder to audit, and requires the agent to understand DynamoDB's pagination model. It also runs a full table scan rather than hitting an index, which is expensive on large tables. And this is a simple question. Anything involving a JOIN across two tables gets significantly more complex.&lt;/p&gt;

&lt;p&gt;LLMs can do so much already. It's frustrating that they still can't reliably handle something this straightforward against a database as widely used as DynamoDB.&lt;/p&gt;

&lt;h2&gt;
  
  
  What teams do about it
&lt;/h2&gt;

&lt;p&gt;Before reaching for a purpose-built solution, most teams working with DynamoDB work through a sequence of workarounds.&lt;/p&gt;

&lt;p&gt;The first option is PartiQL, AWS's own SQL-like syntax for DynamoDB. It's built into the SDK and the console, so there's no setup required. The agent can write queries directly against DynamoDB without any additional infrastructure. The limitation is that PartiQL is a syntax layer, not a query engine. It translates SQL-style statements into native DynamoDB API calls, which means it inherits all of DynamoDB's query constraints. The agent still needs to supply a partition key for efficient queries. There are no JOINs, no GROUP BY, and no native aggregates. The syntax looks like SQL, but the access model is unchanged.&lt;/p&gt;

&lt;p&gt;The second is exporting to S3 and querying with Athena. Engineers set up a DynamoDB export pipeline to S3 and configure Athena to query the exported files. Once that infrastructure is in place, the agent can write SQL against Athena directly. The tradeoff is freshness: the export isn't real-time, so the agent's results are only as current as the last export. Engineers also have to maintain the pipeline and monitor it for failures.&lt;/p&gt;

&lt;p&gt;The third is streaming to a secondary store. Engineers pipe DynamoDB changes to Redshift, OpenSearch, or a relational database, and the agent queries the replica. Once the infrastructure exists, the agent gets a proper query interface. But it's querying a copy of the data, not the source. Engineers have to maintain the pipeline, and when it lags or fails, the agent's results are wrong without any obvious signal that something is off.&lt;/p&gt;

&lt;p&gt;None of these are unreasonable choices. PartiQL is the lowest-friction starting point, but it hits a ceiling fast. The infrastructure-based approaches give the agent a proper query interface, but at the cost of engineering overhead and data that's never fully current.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;We ran into this exact problem building an MCP server against DynamoDB. The agent could reason about the data well, but getting it out required knowledge of partition key layouts, GSI structures, and access patterns that wasn't realistic to expect the agent to have upfront.&lt;/p&gt;

&lt;p&gt;So we built an SQL engine on top of DynamoDB and called it DynamoSQL. When the agent submits an ANSI SQL SELECT statement, DynamoSQL parses it, builds a query plan, and runs an optimizer that inspects the table's actual index metadata. The optimizer selects the best access path: a partition key Query if the WHERE clause supports it, a GSI Query if a matching index exists, or a Scan as a last resort. The agent doesn't configure any of this. It just writes SQL.&lt;/p&gt;

&lt;p&gt;The execution layer handles what DynamoDB can't do natively. JOINs run across multiple DynamoDB API calls. GROUP BY, aggregates, subqueries, CTEs, UNION, ORDER BY, and HAVING all work in the engine. Results come back as rows. The agent doesn't need to know anything about the underlying data model.&lt;/p&gt;

&lt;p&gt;The MCP server exposes a single run_sql tool. The agent writes a query, the engine executes it, and the result is a clean set of rows, the same interface you'd expect from any SQL database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves things
&lt;/h2&gt;

&lt;p&gt;SQL databases and LLM agents are a natural fit. The interface is clean, the semantics are well-defined, and the tooling is mature. NoSQL databases are still mostly a compromise: a syntax layer that hits a ceiling, or export pipelines and secondary stores that require engineering overhead and never give the agent fully current data.&lt;/p&gt;

&lt;p&gt;The fragmentation across NoSQL databases makes this hard to solve once and apply everywhere. But for teams using DynamoDB, the SQL layer approach works well in practice. The agent writes what it wants. The engine figures out how to fetch it efficiently.&lt;/p&gt;

&lt;p&gt;The broader question of whether this is the right approach across all NoSQL databases is still open. But the cost of the status quo is real, and it deserves more attention than it's getting.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>aws</category>
      <category>ai</category>
      <category>dynamodb</category>
    </item>
    <item>
      <title>Why We Built an SQL Layer for DynamoDB</title>
      <dc:creator>Camma Smith</dc:creator>
      <pubDate>Thu, 04 Jun 2026 14:35:28 +0000</pubDate>
      <link>https://dev.to/camma_smith_1/why-we-built-an-sql-layer-for-dynamodb-45do</link>
      <guid>https://dev.to/camma_smith_1/why-we-built-an-sql-layer-for-dynamodb-45do</guid>
      <description>&lt;p&gt;The SQL vs. NoSQL debate has been running long enough that most engineers have stopped having it. The real question isn't which is better. It's which tradeoffs you're actually making when you choose one.&lt;/p&gt;

&lt;p&gt;SQL databases give you a relational model, flexible ad-hoc queries, decades of tooling, and strong consistency guarantees. The cost is horizontal scale. Distributing a relational database across many nodes is possible, but it adds complexity, and query semantics get harder to reason about as you do it. At extreme read/write throughput, relational databases require significant engineering effort to keep performant.&lt;/p&gt;

&lt;p&gt;NoSQL databases flip those tradeoffs. They're designed to distribute data across nodes predictably, which gives you horizontal scale and consistent latency. You gain the ability to handle massive throughput without complex sharding logic. You give up some of the richness of the relational model, particularly around ad-hoc querying.&lt;/p&gt;

&lt;p&gt;DynamoDB is the clearest example of this tradeoff done deliberately and at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why teams choose DynamoDB
&lt;/h2&gt;

&lt;p&gt;DynamoDB is a managed key-value and document store built by AWS. It delivers single-digit millisecond read and write latency at any scale, scales capacity automatically, requires no cluster management, and has a low operational burden compared to running your own database. For teams that don't want to operate infrastructure, it's a compelling default for production workloads.&lt;/p&gt;

&lt;p&gt;The data model is built around partition keys. Every item in a table has a partition key, which determines which physical partition holds that item. An optional sort key allows range queries within a partition. Global Secondary Indexes (GSIs) let you define alternative partition and sort key layouts, enabling multiple read patterns without duplicating your full dataset.&lt;/p&gt;

&lt;p&gt;For workloads where access patterns are known and stable, DynamoDB performs exceptionally well. An e-commerce platform with clear paths for "get order by ID", "list orders for customer", and "get product by SKU" can serve all of them from DynamoDB efficiently and cheaply. Each access pattern gets an index. Each query hits that index. Performance is predictable.&lt;/p&gt;

&lt;p&gt;The catch is that this performance guarantee comes with a hard constraint: you must define your access patterns before you write your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The querying problem
&lt;/h2&gt;

&lt;p&gt;DynamoDB's Query API requires a partition key value. You can filter on sort key attributes and apply additional filter expressions, but the partition key is always mandatory. Without it, the only option is Scan, which reads every item in the table and applies filters afterward.&lt;/p&gt;

&lt;p&gt;Scans are expensive. They consume read capacity proportional to the data they touch, not the data they return. On a large table, a Scan that returns 100 rows might read millions of items to get there. That's costly in both time and money, and it gets worse as your table grows.&lt;/p&gt;

&lt;p&gt;This creates a specific class of queries that DynamoDB handles poorly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregates.&lt;/strong&gt; There's no native SUM, COUNT, AVG, or GROUP BY. To know how many orders were placed by region last week, you fetch the rows and compute the aggregate yourself. For small datasets that's workable. For anything large, you're pulling significant data across the wire and doing compute work in your application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JOINs.&lt;/strong&gt; DynamoDB has no cross-table JOIN. Single-table design, a common DynamoDB modeling pattern, works around this by denormalizing related entities into one table. But that requires anticipating every join at schema design time. It makes your data model harder to evolve and makes ad-hoc analysis across related entities painful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ad-hoc queries.&lt;/strong&gt; Questions that weren't anticipated at schema design time have no efficient answer. The data exists. Getting it in an arbitrary shape requires either a full-table Scan or redesigning your access patterns, which often means a new GSI and a backfill.&lt;/p&gt;

&lt;p&gt;These aren't edge cases. They're the normal requirements of anyone who needs to understand what's in their database beyond the access patterns the application was originally built around.&lt;/p&gt;

&lt;h2&gt;
  
  
  What teams do about it
&lt;/h2&gt;

&lt;p&gt;Several approaches exist. Each involves real tradeoffs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application-level computation.&lt;/strong&gt; Fetch the relevant rows from DynamoDB and compute aggregates or join logic in application code. This works at small data volumes. At larger volumes, you're moving significant data across the network and doing CPU-heavy work in your API layer. It also spreads data logic across application code rather than keeping it at the data layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Export to S3 and Athena.&lt;/strong&gt; DynamoDB supports point-in-time exports to S3. Athena can then run SQL against those files. The tradeoff is freshness: exports aren't real-time, so your query results are as stale as your last export. For some reporting use cases that's acceptable. For anything that needs current data, it isn't. You also now have two systems to understand and operate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DynamoDB Streams to a secondary store.&lt;/strong&gt; Stream DynamoDB changes to Redshift, OpenSearch, or a relational database, and run queries against the replica. This can give you full SQL against a continuously synced copy of your data. The tradeoffs are real: operational complexity, eventual consistency between primary and replica, and the cost of maintaining both systems. When the pipeline works, it works. When it lags or fails, your results are wrong and diagnosing the problem requires understanding two systems at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PartiQL.&lt;/strong&gt; AWS added PartiQL, an SQL-like syntax, to DynamoDB in 2020. PartiQL translates SQL-style statements into native DynamoDB API calls. A SELECT with a partition key condition becomes a Query. Without one, it becomes a Scan. There's no JOIN support, no GROUP BY, and no aggregates. PartiQL gives DynamoDB an SQL-style syntax. It doesn't change DynamoDB's underlying access model. It's a syntax layer, not a query engine.&lt;/p&gt;

&lt;p&gt;Each of these approaches solves part of the problem. None of them gives you real-time SQL with full query capabilities directly against your DynamoDB tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;That's what led us to build DynamoSQL, a read-only SQL engine that translates ANSI SQL SELECT statements into DynamoDB API calls and executes them directly against your tables, in real time.&lt;/p&gt;

&lt;p&gt;When you submit an ANSI SQL SELECT statement to DynamoSQL, it parses the SQL into an AST, builds a logical query plan, and runs an optimizer that inspects your table's actual index metadata. The optimizer selects the best access path: a partition key Query if your WHERE clause supports it, a GSI Query if a matching index exists, or a Scan as a last resort. Then it executes the plan against DynamoDB and returns results as JSON rows.&lt;/p&gt;

&lt;p&gt;The execution layer handles what DynamoDB can't do natively. JOINs are computed across multiple DynamoDB API calls. GROUP BY, aggregates (SUM, COUNT, AVG, MIN, MAX), subqueries, CTEs, UNION, INTERSECT, EXCEPT, ORDER BY, HAVING, and a wide function library all run in the engine. You write standard SQL. DynamoSQL figures out how to execute it efficiently.&lt;/p&gt;

&lt;p&gt;This is different from the Athena approach because it's real-time. No export pipeline, no stale snapshots. It's different from streaming to a secondary store because there's no replica to maintain and no pipeline to monitor. It's different from PartiQL because it's an actual query engine, not a syntax translation layer.&lt;/p&gt;

&lt;p&gt;It's also read-only. DynamoSQL supports SELECT only. DynamoDB's write model is precise by design: partition keys, sort keys, defined capacity allocations. Wrapping writes in SQL semantics would make the behavior harder to reason about, not easier. The read side is where the gap is, and that's what we're solving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP server
&lt;/h2&gt;

&lt;p&gt;The SQL engine solves the querying problem for developers who write code. The MCP server extends that to a different kind of user: anyone interacting with an AI agent.&lt;/p&gt;

&lt;p&gt;MCP, or Model Context Protocol, is the standard interface AI agents use to call external tools. When DynamoSQL is connected to an agent through its MCP server, the agent can query DynamoDB directly using plain SQL through a single tool call.&lt;/p&gt;

&lt;p&gt;Here's what that looks like in practice. A developer or analyst asks the agent a question in natural language: "How many orders were placed in California last week?" The agent doesn't need to know anything about DynamoDB's partition key structure or which indexes exist on the table. It translates the question into SQL, calls run_sql, and gets back rows. DynamoSQL handles the rest: optimizing the query plan, selecting the right index, executing against DynamoDB, and returning structured results.&lt;/p&gt;

&lt;p&gt;This matters because SQL is a reliable middle layer between natural language and a NoSQL database. LLMs write SQL well. They've been trained on large amounts of it, the syntax is unambiguous, and errors are easy to catch. Asking an agent to write DynamoDB SDK code to answer the same question is a different proposition: the code is longer, harder to verify, and requires the agent to know the table's internal structure upfront.&lt;/p&gt;

&lt;p&gt;The SQL layer gives the agent a well-defined interface. The agent writes what it wants. DynamoSQL figures out how to fetch it. The result is more reliable than asking the agent to navigate DynamoDB's access model directly, and more accessible than expecting every user to write SQL by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it deploys
&lt;/h2&gt;

&lt;p&gt;DynamoSQL runs as a managed SaaS. You connect your AWS account, register your DynamoDB tables under a schema, and run queries through the REST API or the MCP server. Your data stays in your AWS account. DynamoSQL assumes a read-only IAM role you create and control, runs queries, and returns results. We don't store your data and we don't have standing access to your tables.&lt;/p&gt;

&lt;p&gt;If you're using DynamoDB and want real-time SQL read access against your tables, the product is live. &lt;a href="https://aws.amazon.com/marketplace/pp/prodview-o2ddsn4oox2sy" rel="noopener noreferrer"&gt;AWS Marketplace Listing&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;DynamoDB is a good database. The teams that choose it make a deliberate call: predictable performance, no cluster management, strong operational guarantees at scale. The querying limitation isn't a reason to abandon it. It's a gap that can be filled without changing anything about how your data is stored or your tables are structured.&lt;/p&gt;

&lt;p&gt;DynamoSQL sits on top of what you already have. You bring the DynamoDB tables. We bring the SQL engine, the query optimizer, and the MCP server. The access patterns you've already defined become inputs to the optimizer, not constraints you have to work around.&lt;/p&gt;

&lt;p&gt;If you're hitting the querying wall and want to see how it works against your own tables, we'd like to hear from you. &lt;a href="https://dynamosql.com/" rel="noopener noreferrer"&gt;Dynamosql.com&lt;/a&gt;&lt;/p&gt;

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