<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DynoTable</title>
    <description>The latest articles on DEV Community by DynoTable (@dynotable).</description>
    <link>https://dev.to/dynotable</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4014872%2F0b044873-dd8b-4089-b6ea-b058a67bc3dd.jpg</url>
      <title>DEV Community: DynoTable</title>
      <link>https://dev.to/dynotable</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dynotable"/>
    <language>en</language>
    <item>
      <title>DynamoDB Limits and Quotas, Verified on the Live Service</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Thu, 24 Sep 2026 14:13:01 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodb-limits-and-quotas-verified-on-the-live-service-443e</link>
      <guid>https://dev.to/dynotable/dynamodb-limits-and-quotas-verified-on-the-live-service-443e</guid>
      <description>&lt;h2&gt;
  
  
  What are the DynamoDB limits?
&lt;/h2&gt;

&lt;p&gt;An item is capped at 400 KB, a partition key at 2,048 bytes and a sort key at 1,024&lt;br&gt;
bytes. A batch write takes 25 items, a batch get 100 keys, a transaction 100 actions.&lt;br&gt;
A table gets 20 global and 5 local secondary indexes. Every one of those numbers on&lt;br&gt;
this page was established by sending the request to Amazon DynamoDB and reading what&lt;br&gt;
came back.&lt;/p&gt;

&lt;h2&gt;
  
  
  How these numbers were established
&lt;/h2&gt;

&lt;p&gt;AWS publishes its quotas without evidence, and that is normally fine — until a number&lt;br&gt;
is load-bearing for a design decision and you want to know whether it means 400,000&lt;br&gt;
bytes or 409,600, whether it counts your attribute names, and what exactly the service&lt;br&gt;
says when you cross it.&lt;/p&gt;

&lt;p&gt;So we probed them. For every limit in the first table below, a request was built to sit&lt;br&gt;
&lt;strong&gt;exactly on&lt;/strong&gt; the documented value and sent to the live service in &lt;code&gt;us-east-1&lt;/code&gt;; then a&lt;br&gt;
second request, one unit past it. The first must be accepted and the second rejected —&lt;br&gt;
that pair is what locates the edge, rather than taking the documentation's word for it.&lt;br&gt;
The rejection message in the last column is the service's own sentence, captured&lt;br&gt;
verbatim and never retyped.&lt;/p&gt;

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

&lt;p&gt;The probes are a script in this site's repository, and the&lt;br&gt;
captured output is committed as generated JSON. The tables below are built from that&lt;br&gt;
JSON, so an error message cannot drift out of sync with what the engine actually says —&lt;br&gt;
a test fails the build if it does.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Four rows could only be established from the rejection side. Those are &lt;code&gt;CreateTable&lt;/code&gt;&lt;br&gt;
limits whose accept side would mean building a table with twenty indexes and waiting for&lt;br&gt;
each to become active, for a number the rejection states outright. The &lt;strong&gt;How it was&lt;br&gt;
established&lt;/strong&gt; column says which is which; it is not decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verified limits
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;How it was established&lt;/th&gt;
&lt;th&gt;What the service returns when you cross it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Maximum item size&lt;/td&gt;
&lt;td&gt;409,600 bytes&lt;/td&gt;
&lt;td&gt;Accepted at 409,600, rejected at 409,601&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: Item size has exceeded the maximum allowed size&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum partition key value&lt;/td&gt;
&lt;td&gt;2,048 bytes&lt;/td&gt;
&lt;td&gt;Accepted at 2,048, rejected at 2,049&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: One or more parameter values were invalid: Size of hashkey has exceeded the maximum size limit of2048 bytes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum sort key value&lt;/td&gt;
&lt;td&gt;1,024 bytes&lt;/td&gt;
&lt;td&gt;Accepted at 1,024, rejected at 1,025&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: One or more parameter values were invalid: Aggregated size of all range keys has exceeded the size limit of 1024 bytes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum nesting depth&lt;/td&gt;
&lt;td&gt;32 levels&lt;/td&gt;
&lt;td&gt;Accepted at 32, rejected at 33&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: 1 validation error detected: Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum expression length&lt;/td&gt;
&lt;td&gt;4,096 bytes&lt;/td&gt;
&lt;td&gt;Accepted at 4,096, rejected at 4,097&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: 1 validation error detected: Invalid ConditionExpression: Expression size has exceeded the maximum allowed size;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum items per BatchWriteItem&lt;/td&gt;
&lt;td&gt;25 items&lt;/td&gt;
&lt;td&gt;Accepted at 25, rejected at 26&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: 1 validation error detected: Value '&amp;lt;your request&amp;gt;' at 'requestItems' failed to satisfy constraint: Map value must satisfy constraint: [Member must have length less than or equal to 25, Member must have length greater than or equal to 1]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum keys per BatchGetItem&lt;/td&gt;
&lt;td&gt;100 items&lt;/td&gt;
&lt;td&gt;Accepted at 100, rejected at 101&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: 1 validation error detected: Value at 'RequestItems.&amp;lt;table-name&amp;gt;.member.Keys' failed to satisfy constraint: Member must have length less than or equal to 100&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum actions per TransactWriteItems&lt;/td&gt;
&lt;td&gt;100 items&lt;/td&gt;
&lt;td&gt;Accepted at 100, rejected at 101&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: 1 validation error detected: Value '&amp;lt;your request&amp;gt;' at 'transactItems' failed to satisfy constraint: Member must have length less than or equal to 100&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global secondary indexes per table&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;Rejection only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: One or more parameter values were invalid: GlobalSecondaryIndex count exceeds the per-table limit of 20&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local secondary indexes per table&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Rejection only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: One or more parameter values were invalid: Number of LocalSecondaryIndexes exceeds per-table limit of 5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Projected non-key attributes per index&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;Rejection only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: 1 validation error detected: Value '&amp;lt;your request&amp;gt;' at 'globalSecondaryIndexes.1.member.projection.nonKeyAttributes' failed to satisfy constraint: Member must have length less than or equal to 20&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Projected non-key attributes per table&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;Rejection only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ValidationException: One or more parameter values were invalid: Number of projected attributes in all indexes exceeds limit of 100, number of projected attributes:120&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Environment: Amazon DynamoDB, live service, &lt;code&gt;us-east-1&lt;/code&gt;, probed 2026-08-27 with the AWS&lt;br&gt;
SDK for JavaScript v3.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the probes turned up
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;400 KB means 409,600 bytes, and it counts your attribute names.&lt;/strong&gt; An item measured at&lt;br&gt;
exactly 409,600 bytes was accepted; 409,601 was rejected. The measurement counts the&lt;br&gt;
UTF-8 length of every attribute name plus every value, which is the same accounting our&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;item size calculator&lt;/a&gt; implements — the probe&lt;br&gt;
builds its payload with that library, so the two agree by construction rather than by&lt;br&gt;
assertion. The deeper modelling problem behind this limit has its own guide:&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-item-size-limit?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;the DynamoDB item size limit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The projected-attributes limit everyone quotes is the wrong one.&lt;/strong&gt; The AWS&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ServiceQuotas.html" rel="noopener noreferrer"&gt;Quotas page&lt;/a&gt;&lt;br&gt;
documents one figure — "up to 100 attributes combined for all of a table's local and&lt;br&gt;
global secondary indexes" — and never mentions a per-index cap. There is one, and it is&lt;br&gt;
&lt;strong&gt;20&lt;/strong&gt;. A &lt;code&gt;CreateTable&lt;/code&gt; projecting 21 non-key attributes into a single index is rejected&lt;br&gt;
before the per-table total is anywhere near 100. The 20 is documented, but only in the&lt;br&gt;
API Reference's&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Projection.html" rel="noopener noreferrer"&gt;&lt;code&gt;Projection&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
page, as an array-member constraint: "Maximum number of 20 items". If you plan an index&lt;br&gt;
against the Quotas page alone, the API will refuse a schema the Quotas page says is&lt;br&gt;
fine. Both numbers are in the table above, each with the rejection that proves it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two of the messages contain AWS's own typos&lt;/strong&gt;, reproduced here rather than silently&lt;br&gt;
corrected — &lt;code&gt;maximum size limit of2048 bytes&lt;/code&gt; is missing a space, and&lt;br&gt;
&lt;code&gt;number of projected attributes:120&lt;/code&gt; is missing another. If you are grepping logs for&lt;br&gt;
these strings, grep for what the service sends, not for what reads correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sort keys are measured in aggregate.&lt;/strong&gt; The sort-key rejection says "Aggregated size of&lt;br&gt;
all range keys", not "the sort key", because the same 1,024-byte budget covers the sort&lt;br&gt;
key of the table and of every local secondary index the item lands in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1 MB page, which raises nothing at all
&lt;/h2&gt;

&lt;p&gt;Every limit above announces itself by rejecting you. The Query and Scan page limit does&lt;br&gt;
not. Cross it and DynamoDB returns a short page and a &lt;code&gt;LastEvaluatedKey&lt;/code&gt;, with no error&lt;br&gt;
and no warning — which is why "my Scan only returned part of the table" is such a common&lt;br&gt;
surprise, and why&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-pagination?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;pagination&lt;/a&gt; is not optional.&lt;/p&gt;

&lt;p&gt;That also means there is no error message to quote, so it was measured instead of&lt;br&gt;
provoked:&lt;/p&gt;

&lt;p&gt;At &lt;strong&gt;1,000 bytes an item&lt;/strong&gt;, a page held &lt;strong&gt;1,029 items&lt;/strong&gt; and returned a &lt;code&gt;LastEvaluatedKey&lt;/code&gt; — 1,029,000 bytes of item data, with the 1,030th item left for the next request. At &lt;strong&gt;5,000 bytes an item&lt;/strong&gt;, a page held &lt;strong&gt;208 items&lt;/strong&gt; and returned a &lt;code&gt;LastEvaluatedKey&lt;/code&gt; — 1,040,000 bytes of item data, with the 209th item left for the next request.&lt;/p&gt;

&lt;p&gt;Neither page held 1 MiB of item data — the first fell about 19,576 bytes short. So the page budget charges more per item than the item's own bytes.&lt;/p&gt;

&lt;p&gt;Two probes at different item sizes are enough to pin that down. Treating a page as &lt;code&gt;items × (item bytes + per-item overhead) ≤ budget&lt;/code&gt;, only &lt;strong&gt;7 whole-byte overheads&lt;/strong&gt; are consistent with both measurements, and exactly one of them puts the budget on a round binary megabyte: an overhead of &lt;strong&gt;19 bytes an item&lt;/strong&gt;, with the budget between 1,048,551 and 1,048,971 bytes — a range containing &lt;strong&gt;1,048,576&lt;/strong&gt;. DynamoDB's "1 MB" is binary, as its own quotas page states, and it is spent on item bytes &lt;em&gt;plus&lt;/em&gt; per-item overhead. Budget for roughly 19 bytes of it per item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quotas we did not probe
&lt;/h2&gt;

&lt;p&gt;The limits below are &lt;strong&gt;cited to AWS, not measured.&lt;/strong&gt; They are account-level quotas: most&lt;br&gt;
are adjustable on request, and reaching them means provisioning throughput that bills by&lt;br&gt;
the hour, creating thousands of tables, or committing to a year of reserved capacity.&lt;br&gt;
None of that is a probe, so none of it is presented as one. The source for every row is&lt;br&gt;
the AWS&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ServiceQuotas.html" rel="noopener noreferrer"&gt;Quotas in Amazon DynamoDB&lt;/a&gt;&lt;br&gt;
page.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quota&lt;/th&gt;
&lt;th&gt;Default value&lt;/th&gt;
&lt;th&gt;Adjustable&lt;/th&gt;
&lt;th&gt;Why we did not probe it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tables per account per Region&lt;/td&gt;
&lt;td&gt;2,500&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Creating 2,500 tables to watch the 2,501st fail leaves an account someone has to unwind.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provisioned throughput per table&lt;/td&gt;
&lt;td&gt;40,000 RCU and 40,000 WCU&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Provisioning 40,000 units bills by the hour whether or not a single request is made.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provisioned throughput per account&lt;/td&gt;
&lt;td&gt;80,000 RCU and 80,000 WCU&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Same reason, twice over — and it changes an account-wide setting.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On-demand throughput per table&lt;/td&gt;
&lt;td&gt;40,000 RRU and 40,000 WRU&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Reaching it means sustaining 40,000 requests a second, which is a load test with a bill.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active reserved capacity per account&lt;/td&gt;
&lt;td&gt;1,000,000 capacity units&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Reserved capacity is a one-year purchase commitment, not a probe.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table size&lt;/td&gt;
&lt;td&gt;No practical limit&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;AWS states tables are unconstrained in items and bytes; there is no edge to find.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;Because these are adjustable, your account's values may differ from the defaults. Check&lt;br&gt;
the Service Quotas console for what your account is actually set to before designing&lt;br&gt;
around any of them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Throughput and partition limits
&lt;/h2&gt;

&lt;p&gt;The quotas above are account bookkeeping; these three are architecture, and they are&lt;br&gt;
the limits that actually throttle production tables. All are &lt;strong&gt;cited to AWS, not&lt;br&gt;
probed&lt;/strong&gt; — a throughput edge cannot be probed politely, because finding it means&lt;br&gt;
sustaining the load that crosses it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3,000 RCU and 1,000 WCU per second, per partition.&lt;/strong&gt; However much capacity the
table has, the slice any single partition can serve is capped — and a single
partition key value lives in exactly one partition. A key hot enough to cross
either number throttles even while the rest of the table sits idle, and adding
table capacity does not raise it. This is the
&lt;a href="https://dynotable.com/learn/dynamodb-hot-partition?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;hot partition&lt;/a&gt; problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 GB per item collection — only on tables with a local secondary index.&lt;/strong&gt; An
item collection is every item sharing one partition key value, base table plus LSI
entries. With an LSI the collection is capped at 10 GB and further writes are
rejected with &lt;code&gt;ItemCollectionSizeLimitExceededException&lt;/code&gt;; without one, a partition
key value's storage is unbounded and simply splits across partitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-demand ramps to double the previous peak.&lt;/strong&gt; A new on-demand table serves up
to 4,000 writes and 12,000 reads per second immediately. Beyond that it scales to
twice whatever it has already sustained — but a spike past double the previous
peak within 30 minutes can be throttled while DynamoDB splits partitions. Load
that doubles gradually never notices; load that jumps tenfold in a minute does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sources: the AWS&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.Partitions.html" rel="noopener noreferrer"&gt;partitions and data distribution&lt;/a&gt;&lt;br&gt;
page for the per-partition figures, the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LSI.html" rel="noopener noreferrer"&gt;LSI developer-guide page&lt;/a&gt;&lt;br&gt;
for the item-collection cap, and the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/on-demand-capacity-mode.html" rel="noopener noreferrer"&gt;on-demand mode page&lt;/a&gt;&lt;br&gt;
for the ramp behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which limits you should design around
&lt;/h2&gt;

&lt;p&gt;Most of these you will never meet. The handful that shape real designs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;400 KB per item&lt;/strong&gt; is a modelling constraint, not a quota. An item that approaches it
is usually an unbounded one-to-many relationship stored as an embedded list. See
&lt;a href="https://dynotable.com/learn/dynamodb-item-size-limit?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;the item size limit&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 1 MB page&lt;/strong&gt; governs every Query and Scan you write. Code that ignores
&lt;code&gt;LastEvaluatedKey&lt;/code&gt; is quietly wrong the day your data outgrows one page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;25 items per batch write and 100 per batch get&lt;/strong&gt; shape your bulk-load loops. See
&lt;a href="https://dynotable.com/learn/dynamodb-batch-operations?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;batch operations&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;100 actions per transaction&lt;/strong&gt; is the one people hit while trying to make DynamoDB
behave relationally. See &lt;a href="https://dynotable.com/learn/dynamodb-transactions?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;transactions&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 GSIs, 5 LSIs, 100 projected attributes&lt;/strong&gt; constrain access-pattern design far more
often than the throughput quotas do, and the LSI count is fixed the moment the table is
created. See &lt;a href="https://dynotable.com/learn/dynamodb-index-projections?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;index projections&lt;/a&gt; and
&lt;a href="https://dynotable.com/learn/dynamodb-gsi-vs-lsi?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;GSI vs LSI&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every rejection quoted above was captured from the request that produces it; the four&lt;br&gt;
&lt;code&gt;CreateTable&lt;/code&gt; rejections were captured specifically for this page.&lt;/p&gt;

&lt;p&gt;To check a single item against the 400 KB line without writing it,&lt;br&gt;
the &lt;a href="https://dynotable.com/tools/dynamodb-item-size-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;item size calculator&lt;/a&gt; runs in your browser.&lt;br&gt;
To look at the items in your own tables, &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-limits" rel="noopener noreferrer"&gt;DynoTable&lt;/a&gt; is a desktop DynamoDB&lt;br&gt;
client.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What is the DynamoDB item size limit?&lt;/strong&gt;&lt;br&gt;
400 KB per item — 409,600 bytes exactly, verified by probe, counting the UTF-8 bytes&lt;br&gt;
of every attribute name and value. An item that approaches it is usually an unbounded&lt;br&gt;
relationship stored as an embedded list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much throughput can a single DynamoDB partition handle?&lt;/strong&gt;&lt;br&gt;
3,000 read capacity units and 1,000 write capacity units per second. The cap is per&lt;br&gt;
partition, and one partition key value never spans partitions, so a hot key throttles&lt;br&gt;
at those numbers no matter how much capacity the table has.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the 10 GB limit in DynamoDB?&lt;/strong&gt;&lt;br&gt;
It is the item-collection cap, and it exists only on tables with a local secondary&lt;br&gt;
index: all items sharing one partition key value, across the base table and its LSIs,&lt;br&gt;
must fit in 10 GB. Tables without an LSI have no such cap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a limit on DynamoDB table size?&lt;/strong&gt;&lt;br&gt;
No practical one — AWS places no ceiling on a table's item count or total bytes. The&lt;br&gt;
limits that bind are per item (400 KB), per page (1 MB), and per partition (3,000 RCU&lt;br&gt;
and 1,000 WCU).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many actions fit in one DynamoDB transaction?&lt;/strong&gt; 100. A &lt;code&gt;TransactWriteItems&lt;/code&gt; with 100 actions is accepted and one with 101 is&lt;br&gt;
rejected — both verified against the live service, with the rejection quoted in the&lt;br&gt;
table above.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>How many writes per second before a new DynamoDB table throttles?</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Fri, 28 Aug 2026 07:45:04 +0000</pubDate>
      <link>https://dev.to/dynotable/dynamodbs-fresh-table-write-ceiling-is-4130s-not-4000-we-measured-it-20l1</link>
      <guid>https://dev.to/dynotable/dynamodbs-fresh-table-write-ceiling-is-4130s-not-4000-we-measured-it-20l1</guid>
      <description>&lt;p&gt;AWS documents that a brand-new &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=measuring-dynamodb-on-demand-throughput#on-demand" rel="noopener noreferrer"&gt;On-demand capacity&lt;/a&gt; table serves "up to 4,000 write request units per second" out of the box. &lt;a href="https://theburningmonk.com/2019/03/understanding-the-scaling-behaviour-of-dynamodb-ondemand-tables/" rel="noopener noreferrer"&gt;The study everyone still cites for what actually happens&lt;/a&gt; — the one Capital One and ScyllaDB both link — measured it in 2019, before warm throughput, before configurable maximums, before the current scaling rules existed. As far as we can tell, nobody has published a measurement since.&lt;/p&gt;

&lt;p&gt;So we ran one. On 2026-08-27, against a table created minutes earlier in us-east-1, offered write load ramped from 1,000 to 8,000 requests per second:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offered&lt;/th&gt;
&lt;th&gt;Achieved&lt;/th&gt;
&lt;th&gt;Throttled requests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000/s&lt;/td&gt;
&lt;td&gt;1,000/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,000/s&lt;/td&gt;
&lt;td&gt;2,000/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3,000/s&lt;/td&gt;
&lt;td&gt;3,000/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4,000/s&lt;/td&gt;
&lt;td&gt;4,000/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000/s&lt;/td&gt;
&lt;td&gt;4,132/s&lt;/td&gt;
&lt;td&gt;25,992&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6,000/s&lt;/td&gt;
&lt;td&gt;4,131/s&lt;/td&gt;
&lt;td&gt;55,966&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,000/s&lt;/td&gt;
&lt;td&gt;4,134/s&lt;/td&gt;
&lt;td&gt;115,922&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The documented baseline holds, and it is slightly conservative: the service accepted everything up to 4,000/s without a single rejection, then pinned at &lt;strong&gt;4,130 ±2 writes per second&lt;/strong&gt; no matter how hard we pushed. Three windows, three offered rates, the same ceiling to within 0.05%. Reads never throttled at all — we drove a seeded table past &lt;strong&gt;12,700 eventually-consistent reads per second&lt;/strong&gt; and the shortfall above that was our own client, not DynamoDB.&lt;/p&gt;

&lt;p&gt;One table, one day, one region, ~1 KB items with uniformly random keys — no &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=measuring-dynamodb-on-demand-throughput#hot-partition" rel="noopener noreferrer"&gt;Hot partition&lt;/a&gt; in sight. That scope is the honest print on every number here. The rest of this post is how we measured it, including the part where the benchmark failed three times before it worked, and none of the failures were DynamoDB's fault.&lt;/p&gt;

&lt;h2&gt;
  
  
  The throttle is a 400, and it names the wrong suspect first
&lt;/h2&gt;

&lt;p&gt;When the ceiling hits, the error you get is worth reading closely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ThrottlingException: Throughput exceeds the current capacity of your table or index.
DynamoDB is automatically scaling your table or index so please try again shortly.
If exceptions persist, check if you have a hot key:
https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-design.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three observations, all measured:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is an HTTP 400, not a 5xx.&lt;/strong&gt; Your retry policy and your dashboards need to know that. A client that only retries 5xx will drop these on the floor; a monitor that only alarms on 5xx will show a green service while a third of your writes bounce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first throttle arrived 0.9–3.8 seconds into each over-baseline window&lt;/strong&gt; — the service gives you a short grace burst before the ceiling engages, and the higher the offered rate, the sooner it bites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hot-key hint is a default, not a diagnosis.&lt;/strong&gt; Our keys were uniformly random UUIDs; there was no hot key. At small scale, the first suspect for this message is simply the table-level ceiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Latency stayed indifferent to all of it: p50 write latency was 4–5 ms in-region in every window, throttled or not. Rejection is cheap for the service — it does not slow down, it just says no.&lt;/p&gt;

&lt;h2&gt;
  
  
  You cannot measure this from a laptop
&lt;/h2&gt;

&lt;p&gt;Our first instrument was the obvious one: a Node script on a laptop in Madrid. It paced 1,000 writes per second cleanly and collapsed at 2,000 — not because DynamoDB pushed back, but because ~100 ms of Atlantic round-trip means 2,000 in-flight requests per second needs hundreds of concurrent sockets, and the event loop drowned. The service never throttled once. We were benchmarking our own Wi-Fi.&lt;/p&gt;

&lt;p&gt;Attempt two moved the runner into the same region as a single Lambda function. In-region round-trip is ~5 ms, and one 3 GB function paced 2,000 requests per second cleanly at 10 ms p50. Past that it flatlined around 1,100/s with the CPU pinned: request signing and response handling are single-threaded JavaScript, and one runner simply cannot sign 4,000 requests a second. Memory allocated: 3 GB. Memory used: 253 MB. The bottleneck was never RAM — a Lambda's CPU share scales with its memory setting, and we were buying compute, not storage.&lt;/p&gt;

&lt;p&gt;So the final instrument is a &lt;strong&gt;fleet of eight Lambdas&lt;/strong&gt;, each pacing one-eighth of the total offered rate, all started against a shared wall-clock T0 so their windows line up. Eight runners at a comfortable 1,000/s each gave us 8,000/s of offered load with headroom, and the aggregate is a sum of counted requests — no extrapolation anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three runs died before one worked, and DynamoDB was innocent every time
&lt;/h2&gt;

&lt;p&gt;The fleet's first run ended with a runner reporting it had started 882 seconds after T0 — fifteen minutes late to a twenty-second appointment. The second run died with a read timeout. The third, with retries disabled, failed loudly on all eight runners at once. Meanwhile CloudWatch showed every single Lambda finishing its four-minute measurement cleanly, on time, no errors.&lt;/p&gt;

&lt;p&gt;The culprit was the connection between the laptop and Lambda. A synchronous invocation holds one HTTPS connection open, fully silent, for the entire run — and a residential router quietly kills silent connections after a few minutes. The CLI, seeing a dead socket, did the worst possible thing: it &lt;strong&gt;silently retried&lt;/strong&gt;, re-running a measurement Lambda that then found its T0 long gone. A benchmark harness that can invisibly run twice is not a harness; it is a random number generator with an AWS bill.&lt;/p&gt;

&lt;p&gt;The shape that finally worked has three rules we would now use for any long-running remote measurement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fire-and-forget, results out-of-band.&lt;/strong&gt; Runners are invoked asynchronously (the connection closes in milliseconds) and write their results as items into a small DynamoDB table; the driver polls for eight result rows. No connection lives longer than a request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries are off everywhere.&lt;/strong&gt; The measurement client runs at one attempt per request — a retry would silently absorb the throttles we exist to count — and the invocation path has retries disabled too, so no runner can ever execute twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A watchdog instead of a hang.&lt;/strong&gt; Each runner races its schedule against a deadline; if anything wedges, it returns partial counts plus a snapshot of exactly where it was stuck, rather than timing out in silence. A failed run that explains itself costs one read; a hung one costs an evening.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every request also carries an 8-second timeout. The run that hung did so because a single in-flight request with no timeout wedged the final drain step forever. One unbounded wait, per ~4 million requests, was enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the reads did
&lt;/h2&gt;

&lt;p&gt;The read phase ran against a second fresh table seeded with 1,000 items, using &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=measuring-dynamodb-on-demand-throughput#eventually-consistent" rel="noopener noreferrer"&gt;Eventually consistent read&lt;/a&gt; &lt;code&gt;GetItem&lt;/code&gt;s (~1 KB each, 0.5 read units):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offered&lt;/th&gt;
&lt;th&gt;Achieved&lt;/th&gt;
&lt;th&gt;Throttled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4,000/s&lt;/td&gt;
&lt;td&gt;4,000/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,000/s&lt;/td&gt;
&lt;td&gt;7,941/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12,000/s&lt;/td&gt;
&lt;td&gt;11,119/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16,000/s&lt;/td&gt;
&lt;td&gt;12,762/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero throttles, ever. The documented 12,000 read/s baseline holds and we could not find its edge: at 16,000/s offered, five of our eight runners hit their own client-side saturation, so the 12,762/s figure is where &lt;em&gt;our fleet&lt;/em&gt; topped out, not where DynamoDB did. We are saying that plainly rather than dressing it up as a service limit. Reads in-region ran at 2–4 ms p50.&lt;/p&gt;

&lt;p&gt;Two smaller numbers worth keeping: a fresh on-demand table went from &lt;code&gt;CreateTable&lt;/code&gt; to &lt;code&gt;ACTIVE&lt;/code&gt; in 22 seconds on the benchmark run and 7.4 seconds on an earlier probe — budget for the variance, not the best case. And the entire benchmark, 672,116 billed writes and 1.08 million reads, cost &lt;strong&gt;$0.97&lt;/strong&gt;. The instrument is reusable; the experiment is a coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Half an hour of pressure does not double the ceiling
&lt;/h2&gt;

&lt;p&gt;AWS's growth rule says on-demand capacity accommodates up to double your previous&lt;br&gt;
peak. We wanted to see that happen, so after the ceiling run we held a table at&lt;br&gt;
8,000 writes/s of offered load for 34 contiguous minutes and bucketed the&lt;br&gt;
achieved rate per 10 seconds. The shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Minutes under load&lt;/th&gt;
&lt;th&gt;Ceiling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–8&lt;/td&gt;
&lt;td&gt;~4,000/s (baseline, unmoved)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9–25&lt;/td&gt;
&lt;td&gt;~5,000/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;~6,000/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;27–34&lt;/td&gt;
&lt;td&gt;~7,000/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Growth arrives in abrupt ~1,000/s steps, not a ramp — a minute is flat at one&lt;br&gt;
rate, the next minute is flat at the next. The first ceiling is sticky for a&lt;br&gt;
full 8 minutes of continuous over-demand. And after 34 minutes the table served&lt;br&gt;
7,000/s: 1.75× where it started, still short of the offered 8,000 and of a clean&lt;br&gt;
doubling. If your launch needs more than ~4,000 writes/s on a new table, warm it&lt;br&gt;
up ahead of time or set its maximum on-demand throughput explicitly — the growth&lt;br&gt;
mechanism is real but it is neither instant nor generous on your schedule. The behavior is stable, too: under 9,000/s of offered load, the 2019 study’s table had grown to about 7,000/s when the test ended — the same plateau ours reached seven years later. That&lt;br&gt;
run cost $12.67, the most expensive thing we did all day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What transfers if you measure a cloud service yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put the load generator in the same region as the target. Otherwise you are measuring your route, not the service.&lt;/li&gt;
&lt;li&gt;One Node process tops out around 2,000 signed requests per second regardless of memory; shard the load across runners and sum counted results.&lt;/li&gt;
&lt;li&gt;Disable retries on the measurement path, at every layer. Retries exist to hide exactly what a benchmark exists to see.&lt;/li&gt;
&lt;li&gt;Never hold a silent connection across a long run. Invoke async, deliver results out-of-band, poll.&lt;/li&gt;
&lt;li&gt;Give every request a timeout and every runner a watchdog that returns partial data with a stuck-state snapshot.&lt;/li&gt;
&lt;li&gt;Set a hard operation ceiling per runner so a pacing bug aborts instead of running up the bill, and tear down everything the run created — tables, roles, functions, logs — in a &lt;code&gt;finally&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The reference pages this feeds
&lt;/h2&gt;

&lt;p&gt;The full dataset — every window, every runner, per-latency percentiles, the verbatim error strings — now backs the measured tables in our &lt;a href="https://dynotable.com/learn/dynamodb-limits?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=measuring-dynamodb-on-demand-throughput" rel="noopener noreferrer"&gt;DynamoDB limits reference&lt;/a&gt;, alongside the item-size and page-limit probes we published earlier. If you work against DynamoDB daily, &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=measuring-dynamodb-on-demand-throughput" rel="noopener noreferrer"&gt;DynoTable&lt;/a&gt; is our desktop client for it — the same team, the same habit of checking claims against the live service before repeating them.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>Your agent doesn't need every tool in its context</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sat, 08 Aug 2026 07:54:20 +0000</pubDate>
      <link>https://dev.to/dynotable/your-agent-doesnt-need-every-tool-in-its-context-3bfh</link>
      <guid>https://dev.to/dynotable/your-agent-doesnt-need-every-tool-in-its-context-3bfh</guid>
      <description>&lt;p&gt;DynoTable's &lt;a href="https://dynotable.com/docs/dynamodb-ai-chat?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;AI agent&lt;/a&gt; can reach 38 tools. It rarely&lt;br&gt;
sees all of them at once. Where we drew the line between what the model gets&lt;br&gt;
immediately and what it has to go looking for turned out to be the most&lt;br&gt;
consequential decision in the whole toolkit, and the line ended up nowhere&lt;br&gt;
near where we first put it.&lt;/p&gt;

&lt;p&gt;We built a discovery mechanism so the model would start with a small set and&lt;br&gt;
search for the rest. Then we watched cheap models use it, and moved &lt;strong&gt;27 of&lt;br&gt;
the 38 tools back into the always-visible core&lt;/strong&gt;. The mechanism survived. Our&lt;br&gt;
theory about who needed it did not.&lt;/p&gt;

&lt;p&gt;We learned this building a tool surface for models that can't be&lt;br&gt;
trusted to go looking.&lt;/p&gt;
&lt;h2&gt;
  
  
  A tool the model never calls still costs you
&lt;/h2&gt;

&lt;p&gt;Every tool you expose is its name, its description, and its full input schema,&lt;br&gt;
serialized into the request before the user has typed anything. Thirty-eight of&lt;br&gt;
those is not free.&lt;/p&gt;

&lt;p&gt;Tokens are the smaller half of the bill. The real cost is selection accuracy:&lt;br&gt;
the more near-identical options a model scans, the more often it picks the&lt;br&gt;
wrong one. Our catalog is full of near-identical options &lt;strong&gt;on purpose&lt;/strong&gt;. Five&lt;br&gt;
of our tools exist twice, &lt;code&gt;openTable&lt;/code&gt; and &lt;code&gt;proposeOpenTable&lt;/code&gt;, &lt;code&gt;openWorkbench&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;proposeOpenWorkbench&lt;/code&gt;, and so on. Each pair does the same thing; one does&lt;br&gt;
it immediately, the other emits a chip the user clicks first. That distinction&lt;br&gt;
is load-bearing for &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design#ai-agent" rel="noopener noreferrer"&gt;agent&lt;/a&gt; safety and nearly invisible in a flat&lt;br&gt;
list of names.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design#mcp" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;'s own client guidance leads with this exact&lt;br&gt;
point: loading every tool definition up front wastes tokens, adds latency, and&lt;br&gt;
degrades the model's performance. Agreeing with that is easy. Deciding which&lt;br&gt;
tools lose their seat is where it gets interesting.&lt;/p&gt;
&lt;h2&gt;
  
  
  We built a search tool. The floor model wouldn't call it.
&lt;/h2&gt;

&lt;p&gt;The mechanism is two-tier. A set of tools is active from the first step. The&lt;br&gt;
rest are invisible until the model calls &lt;code&gt;searchTools(query)&lt;/code&gt;, which scores the&lt;br&gt;
catalog on names, descriptions and keywords, returns the matches, and adds them&lt;br&gt;
to the set of tools the model is allowed to call on subsequent steps.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram: &lt;a href="https://dynotable.com/blog/llm-tool-calling-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;view on dynotable.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then we ran it against our floor model. We don't tune this agent against a&lt;br&gt;
frontier model, it runs on&lt;br&gt;
&lt;a href="https://dynotable.com/learn/query-dynamodb-with-ai?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;your own Bedrock credentials&lt;/a&gt;, so people pick&lt;br&gt;
cheap models, and we optimize for the cheapest one. Asked about an attached&lt;br&gt;
file, that model went hunting through the open-tabs listing instead. It&lt;br&gt;
&lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;rarely called the search tool at all&lt;/a&gt;.&lt;br&gt;
Anything not directly visible did not exist to it.&lt;/p&gt;

&lt;p&gt;That result kills the obvious design. If discovery is the only route to a tool,&lt;br&gt;
every request that needs that tool depends on the model choosing to go looking, and the models most likely to need help are the least likely to ask for it.&lt;/p&gt;

&lt;p&gt;So the split stopped being "small core, big tail" and became a question about&lt;br&gt;
the request, not the tool: &lt;strong&gt;does the user's phrasing name the tool?&lt;/strong&gt; The 11&lt;br&gt;
tools we kept discoverable are the ones where the answer is yes. "Export this&lt;br&gt;
to CSV" makes a model search for &lt;em&gt;export&lt;/em&gt;. "Show me last month's orders" does&lt;br&gt;
not make it search for a filter-setting tool, so that one stays inline. Index&lt;br&gt;
statistics, saved specs, relationship introspection, and the staged-change&lt;br&gt;
surfaces are all things a user asks for by name when they want them, and never&lt;br&gt;
implicitly.&lt;/p&gt;

&lt;p&gt;Twenty-seven inline is not a number we would have defended in advance. It's the&lt;br&gt;
number that survived contact with the model we actually ship against.&lt;/p&gt;
&lt;h2&gt;
  
  
  The race that would have made discovery silently useless
&lt;/h2&gt;

&lt;p&gt;Discovery has a timing constraint that is easy to get wrong and hard to notice.&lt;/p&gt;

&lt;p&gt;When the search tool returns matches, those names have to join the allowed set&lt;br&gt;
&lt;em&gt;before&lt;/em&gt; the model's next step is prepared. The obvious place to do that is the&lt;br&gt;
hook that fires when a step completes. That hook is documented to fire, in some&lt;br&gt;
SDK versions, &lt;strong&gt;after&lt;/strong&gt; the next step's preparation, which means the mutation&lt;br&gt;
lands one step too late.&lt;/p&gt;

&lt;p&gt;The failure mode is nasty. The model searches. It gets a correct result naming&lt;br&gt;
the tool it needs. It calls that tool on the very next step and is told the tool&lt;br&gt;
doesn't exist. Intermittent, dependent on which SDK version you resolved, and it&lt;br&gt;
reads like a stupid model rather than a broken runner.&lt;/p&gt;

&lt;p&gt;The fix is to mutate the allowed set inside the search tool's own execution,&lt;br&gt;
which is guaranteed to complete before the loop advances. That is a one-line&lt;br&gt;
difference in where a statement lives, and it is the difference between a&lt;br&gt;
working discovery mechanism and one that fails a fraction of the time for&lt;br&gt;
reasons no one will attribute correctly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three searches, then stop
&lt;/h2&gt;

&lt;p&gt;Search is capped at &lt;strong&gt;3 calls per agent turn&lt;/strong&gt;. The fourth returns this instead&lt;br&gt;
of running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search-budget-exhausted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"budgetCap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cap exists because of a specific loop. The model searches, doesn't find what&lt;br&gt;
it imagined, searches again with a synonym, doesn't find that either, and burns&lt;br&gt;
its entire step budget inside the search tool without ever touching the&lt;br&gt;
database. Capping it forces a decision, commit to one of the tools already&lt;br&gt;
found, or ask the user, at the point where more searching has stopped paying.&lt;/p&gt;

&lt;p&gt;The error message when a model calls a tool it hasn't discovered follows the&lt;br&gt;
same principle we use for&lt;br&gt;
&lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;every validator in the agent&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tool 'startExport' not in active set. Call searchTools(query='startExport')
to discover it, or use one of: &amp;lt;inline tool names&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rejection that names the recovery action costs one extra step. A rejection&lt;br&gt;
that just says no costs the turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  One row per tool, everything else derived
&lt;/h2&gt;

&lt;p&gt;Each tool is declared once, in a single flat list, and the row carries the&lt;br&gt;
tool's entire identity: its name and description, the keywords search matches&lt;br&gt;
on, whether it starts inline or discoverable, which tier it runs in, and how it&lt;br&gt;
is exposed over &lt;a href="https://dynotable.com/learn/dynamodb-mcp-server?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;MCP&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Those tiers matter as much as the visibility split. Twenty-one tools are&lt;br&gt;
&lt;strong&gt;silent&lt;/strong&gt;, reads that run without interrupting anyone. Sixteen are &lt;strong&gt;gated&lt;/strong&gt;&lt;br&gt;
behind the authorization ladder. Exactly one belongs to neither, because the&lt;br&gt;
search tool isn't a capability the agent uses on your data; it's part of the&lt;br&gt;
loop itself. MCP exposure is a third axis on the same row: read-only, staging,&lt;br&gt;
full, or excluded outright, which three tools are.&lt;/p&gt;

&lt;p&gt;The rule that keeps this honest is that &lt;strong&gt;every other list in the system is&lt;br&gt;
derived from those rows&lt;/strong&gt;, the silent-tier set, the MCP scope tiers, the&lt;br&gt;
write-scoped set, and none of them is maintained by hand. A hand-kept silent&lt;br&gt;
list next to a hand-kept MCP list is exactly how a tool ends up correctly gated&lt;br&gt;
in chat and quietly ungated to an external client.&lt;/p&gt;

&lt;p&gt;The constraint we didn't see coming is that the declaration list has to contain&lt;br&gt;
&lt;strong&gt;zero runtime imports&lt;/strong&gt;. It's shared by the desktop UI and the backend, and a&lt;br&gt;
single import reaches, transitively, a Node-only crypto dependency by way of one&lt;br&gt;
tool's implementation. Pull that into the browser bundle and the app fails at&lt;br&gt;
module load. Neither the type checker nor the unit tests catch it, both resolve&lt;br&gt;
the import happily. What catches it is a test that reads the file as text and&lt;br&gt;
fails on any import statement at all, which feels crude right up to the first&lt;br&gt;
time it saves you.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Count your tools before you defend your architecture. The right split is a
measurement, not a principle.&lt;/li&gt;
&lt;li&gt;Test discovery against your &lt;strong&gt;weakest&lt;/strong&gt; model. A frontier model will search
when it should; that tells you nothing about the model your users pick.&lt;/li&gt;
&lt;li&gt;Decide visibility by whether the user's own phrasing names the tool. Tools
invoked implicitly belong inline; tools people ask for by name can be found.&lt;/li&gt;
&lt;li&gt;Check when your framework's step hooks actually fire before you put anything
order-sensitive in one.&lt;/li&gt;
&lt;li&gt;Cap the meta-tools. Anything that can be called repeatedly without touching
real state will be, and a step budget spent searching is a turn wasted.&lt;/li&gt;
&lt;li&gt;Make undiscovered-tool errors name the recovery call, the same as any other
validator error.&lt;/li&gt;
&lt;li&gt;Declare each tool once and derive every other list from it. Two hand-kept
lists of the same tools disagree eventually, and the disagreement shows up at
a security boundary.&lt;/li&gt;
&lt;li&gt;If a module carries a load-bearing constraint your compiler can't express,
write the crude test that enforces it as text.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;All of this ships inside &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;DynoTable&lt;/a&gt;'s&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-ai-tools?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;tool catalog&lt;/a&gt;, schema-aware querying on your own&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design#bedrock" rel="noopener noreferrer"&gt;AWS Bedrock&lt;/a&gt; credentials, with writes that only ever land in a&lt;br&gt;
reviewable staging area. The same declarations drive the&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-mcp-server?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; that external agents connect to, where&lt;br&gt;
the exposure tier on each row becomes the scope an external client is granted;&lt;br&gt;
&lt;a href="https://dynotable.com/blog/building-dynamodb-mcp-server?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;how we made that safe&lt;/a&gt; (OAuth, consent,&lt;br&gt;
credential isolation) is a separate story.&lt;/p&gt;

&lt;p&gt;And the layer underneath all of it, the validators that make each of these tools&lt;br&gt;
survivable by a cheap model, is&lt;br&gt;
&lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=llm-tool-calling-design" rel="noopener noreferrer"&gt;its own post&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>GPT-6 Luna vs GPT-5.6 Luna vs other cheap models: benchmarked on real agent work</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Thu, 06 Aug 2026 19:21:24 +0000</pubDate>
      <link>https://dev.to/dynotable/gpt-56-luna-vs-the-cheap-models-we-ship-benchmarked-on-real-agent-work-25i4</link>
      <guid>https://dev.to/dynotable/gpt-56-luna-vs-the-cheap-models-we-ship-benchmarked-on-real-agent-work-25i4</guid>
      <description>&lt;p&gt;&lt;em&gt;Updated September 23, 2026: added GPT-6 Luna, released on September 22, and autocomplete results. Two statements in the first version are no longer true and are corrected below: that GPT-5.6 cannot do autocomplete in DynoTable, and that Bedrock's Converse API cannot reach GPT-5.6.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On July 30, OpenAI &lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/07/openai-gpt-terra-luna-pricing-bedrock/" rel="noopener noreferrer"&gt;cut GPT-5.6 Luna's price by 80%&lt;/a&gt;, and the same cut landed on Amazon Bedrock. Seven weeks later GPT-6 Luna arrived at about half of that new price. Every comparison page updated a pricing table. None of them answered the question that matters: at that price, is Luna actually good enough to drive an agent?&lt;/p&gt;

&lt;p&gt;We had a way to answer it. DynoTable's &lt;a href="https://dynotable.com/docs/dynamodb-ai-chat?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark" rel="noopener noreferrer"&gt;AI agent&lt;/a&gt; runs real agent loops against DynamoDB (tool calls, &lt;a href="https://dynotable.com/learn/dynamodb-partiql-vs-sql?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark" rel="noopener noreferrer"&gt;PartiQL&lt;/a&gt; queries, SQL routing, staged writes) and every model we ship is gated by an &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark#eval" rel="noopener noreferrer"&gt;Eval&lt;/a&gt; suite that scores those loops end to end. So we pointed the suite at both Lunas: &lt;strong&gt;201 test cases, three runs each, against a live database, with every call priced from the tokens it actually used&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The verdict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GPT-6 Luna scored 93.02%&lt;/strong&gt;, GPT-5.6 Luna 93.94%, and Amazon Nova 2 Lite (the cheap model we ship as our tuning floor) &lt;strong&gt;96.00%&lt;/strong&gt;, on the same 201 cells with the same scorers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPT-6 Luna is the cheapest model we have measured: $0.00025 per call&lt;/strong&gt;, 2.4× less than GPT-5.6 Luna and 12× less than Nova 2 Lite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both Lunas now do autocomplete.&lt;/strong&gt; GPT-6 Luna scores 96.97% on it, second only to Claude Haiku 4.5.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A new generation that ties its predecessor on agent work and halves the bill. The numbers are below, plus the caching behavior the pricing tables miss and the transport quirks that cost us debugging time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this benchmark is (and is not)
&lt;/h2&gt;

&lt;p&gt;These are not public benchmark scores. Our suite drives the production agent through real DynamoDB sessions: pick the right tool for a plain question, read an item by composite key, stage a write for human review, respect PartiQL's no-JOIN boundary, route a GROUP BY to the SQL surface instead, ask a clarifying question only when the request is genuinely ambiguous, refuse a "delete everything". A scorer asserts the &lt;em&gt;outcome&lt;/em&gt; of each session, we &lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark" rel="noopener noreferrer"&gt;learned the hard way&lt;/a&gt; what happens when a scorer asserts tool names instead.&lt;/p&gt;

&lt;p&gt;Four honesty notes before any chart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Verified" in our model picker is a coverage claim, not a quality bar.&lt;/strong&gt; It means the eval suite runs against that model. Both Lunas are verified &lt;em&gt;and&lt;/em&gt; scored below our floor model, both facts are published here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nova 2 Lite and Claude Haiku 4.5 are the two models this app ships against&lt;/strong&gt;, so months of prompt tuning have gone toward them specifically. Neither Luna got any tuning. Read the gap with that in mind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPT-6 Luna was measured seven weeks after the others.&lt;/strong&gt; Same 201 cells, same scorers, but our test data was regenerated in between, and two of its cells still expected names from the old copy. In both, GPT-6 Luna read the current row correctly and was marked wrong. We left the score as measured rather than re-grade one model, so its 93.02% is slightly understated. The 0.92-point gap between the two Lunas is inside the run-to-run noise of this suite: read it as a tie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three runs per case absorb flukes, not variance.&lt;/strong&gt; One run going the other way moves a single case by 33 points, so no claim below rests on one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The headline: two Lunas vs Nova 2 Lite
&lt;/h2&gt;

&lt;p&gt;Measured on the 201 test cases all three models ran, after we &lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark" rel="noopener noreferrer"&gt;fixed a bias in how we were grading&lt;/a&gt;, the same yardstick, held to all three:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Cells&lt;/th&gt;
&lt;th&gt;Trials&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Nova 2 Lite&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96.00%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;579&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Luna&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93.94%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;579&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Luna&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93.02%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;579&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap to Nova 2 Lite is real and we are not smoothing it. GPT-5.6 Luna refused a task it should have attempted, routed an indexed top-N query through a full-table pass, and once deferred to the user instead of finishing an export. GPT-6 Luna's misses are its own: asked to read a product, it suggested opening the table instead; asked to raise a price, it worked out the new value and then asked the user to open the row rather than staging the change. Those are agent-behavior losses, not formatting noise, and they are why Nova 2 Lite remains our floor.&lt;/p&gt;

&lt;p&gt;But the shape of the result is the story. Two untuned models, on a benchmark tuned for someone else's models, landing within two to three points of the incumbent at a fraction of the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What every call actually cost
&lt;/h2&gt;

&lt;p&gt;Sticker prices say Luna should be cheap. The token counts say it is cheaper still. Here is every call we made to these models, priced at current Bedrock rates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Calls&lt;/th&gt;
&lt;th&gt;$/call&lt;/th&gt;
&lt;th&gt;vs GPT-6 Luna&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPT-6 Luna&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2,519&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.000246&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Luna&lt;/td&gt;
&lt;td&gt;5,144&lt;/td&gt;
&lt;td&gt;$0.000591&lt;/td&gt;
&lt;td&gt;2.4×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Nova 2 Lite&lt;/td&gt;
&lt;td&gt;6,303&lt;/td&gt;
&lt;td&gt;$0.003022&lt;/td&gt;
&lt;td&gt;12.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;2,654&lt;/td&gt;
&lt;td&gt;$0.003275&lt;/td&gt;
&lt;td&gt;13.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Terra&lt;/td&gt;
&lt;td&gt;751&lt;/td&gt;
&lt;td&gt;$0.006458&lt;/td&gt;
&lt;td&gt;26.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two notes on the GPT-6 Luna row. Its price, &lt;strong&gt;$0.11 in / $0.55 out per million tokens&lt;/strong&gt;, is derived rather than read off an AWS price table: AWS had not published a GPT-6 price row when we measured, so we used OpenAI's list price plus the 10% Bedrock adds on in-region and geo inference profiles, which is what we called. And its calls include a batch of short autocomplete calls that pull the average down a little; GPT-5.6 Luna's include autocomplete calls from its own August run too. Terra appears for cost only: we ran it across far fewer test cases than the others, and a score from a smaller sample does not belong beside a full one.&lt;/p&gt;

&lt;p&gt;On sticker prices alone, GPT-5.6 Luna ($0.22 in / $1.32 out per million tokens) is already cheaper than Nova 2 Lite ($0.33 / $2.75) on both axes, and GPT-6 Luna halves it again. But the measured gap per call is wider than the stickers explain. The difference is caching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implicit prompt caching nobody configures
&lt;/h2&gt;

&lt;p&gt;An agent loop re-sends a large, mostly-static prompt on every step: system instructions, tool schemas, conversation so far. Whether you pay full price for those repeated tokens is the single biggest cost lever in agent workloads.&lt;/p&gt;

&lt;p&gt;On Bedrock's Converse API, &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html" rel="noopener noreferrer"&gt;prompt caching&lt;/a&gt; is normally opt-in: you place cache breakpoints in the request, and models without cache support simply pay full rate. The OpenAI models behave differently: &lt;strong&gt;caching is implicit and on by default&lt;/strong&gt;. No breakpoints, no request changes, nothing to configure. It held on both transports we measured: GPT-5.6 Luna on Bedrock Mantle, GPT-6 Luna on Converse.&lt;/p&gt;

&lt;p&gt;Share of input charged at the discounted cache rate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Cache-read share of input&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;95.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPT-6 Luna&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPT-5.6 Luna&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;94.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Nova 2 Lite&lt;/td&gt;
&lt;td&gt;71.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across 5,144 calls, GPT-5.6 Luna paid full price on fewer than six input tokens per call. The static prompt prefix was billed at the read rate almost every step, without a cache breakpoint anywhere in our requests. GPT-6 Luna did the same on Converse; its full-price average is higher, 72 tokens per call, only because its short autocomplete prompts have no prefix worth caching. Nova 2 Lite, on explicit Converse caching with a 0.25× read rate (vs 0.1× for the Lunas and Haiku), left 28% of its input at full price, and that, more than the stickers, is why the per-call gap is 12× and not 3×.&lt;/p&gt;

&lt;p&gt;AWS shipped &lt;a href="https://aws.amazon.com/blogs/machine-learning/introducing-explicit-prompt-caching-for-openai-gpt-5-6-models-on-amazon-bedrock/" rel="noopener noreferrer"&gt;explicit cache breakpoints for GPT-5.6&lt;/a&gt; the same day as the July price cut. Our numbers are the &lt;em&gt;default&lt;/em&gt; behavior, what you get before reading that post. For an agent loop with a stable prefix, the default already captures about 95%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two transports, budget a day for these
&lt;/h2&gt;

&lt;p&gt;When we first measured GPT-5.6, it was the first Bedrock family the Converse API could not reach: it lived only on the &lt;code&gt;bedrock-mantle.{region}&lt;/code&gt; endpoint, speaking the OpenAI Responses protocol. That has changed. AWS now publishes &lt;code&gt;us.&lt;/code&gt; and &lt;code&gt;global.&lt;/code&gt; inference profiles for GPT-5.6 that Converse accepts, and GPT-6 launched on Converse. The bare GPT-5.6 model ID still goes to Mantle, and four things there cost us real debugging time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;temperature&lt;/code&gt; is rejected, not ignored.&lt;/strong&gt; OpenAI's own endpoint silently drops unsupported sampling parameters; Mantle returns &lt;code&gt;400 unsupported_parameter&lt;/code&gt;. Our first full run failed everywhere on this before a single token was generated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The base URL is &lt;code&gt;/openai/v1&lt;/code&gt;, not &lt;code&gt;/v1&lt;/code&gt;.&lt;/strong&gt; The wrong path also returns a 400, one that reads misleadingly like an entitlement problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fresh account gets 401s until the AWS Marketplace subscription for the model is active.&lt;/strong&gt; The error text talks about authorization, not subscriptions; if you hit it, check Marketplace first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asking for a reasoning summary is a 400.&lt;/strong&gt; When you set a reasoning effort, the OpenAI SDK asks for a detailed reasoning summary by default, and Bedrock now refuses the parameter, so every GPT-5.6 turn at low, medium or high effort failed until we sent an explicit "no summary". Nothing is lost by it: Bedrock returns no reasoning text for OpenAI models on either transport.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic once you know it, but no pricing table mentions any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full ladder: 12 models, one instrument, one day
&lt;/h2&gt;

&lt;p&gt;After the July price cut we re-ran every model we tracked in one session (12 models, 39 scenarios, 3 runs each, $20.49 of Bedrock spend) so every row below is measured the same way on the same day. The table uses the 36 scenarios &lt;strong&gt;all twelve models ran&lt;/strong&gt;. The three autocomplete scenarios are left out because at the time our autocomplete could not reach the GPT-5.6 models; that is fixed, and autocomplete has its own section below.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;$/agent run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Claude Haiku 4.5 (1)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100.00%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.0100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Claude Sonnet 4.5 &lt;em&gt;(ceiling ref)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;99.69%&lt;/td&gt;
&lt;td&gt;$0.0290&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Amazon Nova 2 Lite (1)&lt;/td&gt;
&lt;td&gt;97.22%&lt;/td&gt;
&lt;td&gt;$0.0091&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;GPT-5.6 Luna&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93.60%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.0024&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;GPT-5.6 Terra &lt;em&gt;(reference)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;92.82%&lt;/td&gt;
&lt;td&gt;$0.0224&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;MiniMax M2 (2)&lt;/td&gt;
&lt;td&gt;92.81%&lt;/td&gt;
&lt;td&gt;$0.0178&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Nova Pro (Gen 1)&lt;/td&gt;
&lt;td&gt;90.66%&lt;/td&gt;
&lt;td&gt;$0.0150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Nova Lite (Gen 1)&lt;/td&gt;
&lt;td&gt;90.36%&lt;/td&gt;
&lt;td&gt;$0.0013&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;DeepSeek V3.2 (2)&lt;/td&gt;
&lt;td&gt;90.17%&lt;/td&gt;
&lt;td&gt;$0.0508&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Ministral 8B (2)&lt;/td&gt;
&lt;td&gt;89.43%&lt;/td&gt;
&lt;td&gt;$0.0104&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;Ministral 3B (2)&lt;/td&gt;
&lt;td&gt;88.53%&lt;/td&gt;
&lt;td&gt;$0.0082&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Nova Micro&lt;/td&gt;
&lt;td&gt;86.96%&lt;/td&gt;
&lt;td&gt;$0.0008&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;(1)&lt;/strong&gt; The two models this app ships against, months of prompt tuning went toward them specifically, and Haiku's perfect score should be read with that squarely in view. &lt;strong&gt;(2)&lt;/strong&gt; No Bedrock prompt caching, so every step of an agent loop pays full input rate, which is how DeepSeek ends up costing &lt;strong&gt;5× Claude Haiku per run&lt;/strong&gt; while scoring seven points below it.&lt;/p&gt;

&lt;p&gt;An agent run here is a full multi-step session (tool calls, queries, staged writes) and the dollar column is what one such session costs, averaged over the 100+ we ran per model.&lt;/p&gt;

&lt;p&gt;Four readings we'd defend from this table, and one we wouldn't. GPT-5.6 Luna is the only model in the top half whose cost sits in the bottom half, the price-performance outlier of the ladder. &lt;strong&gt;Terra, the mid-tier GPT-5.6 at nearly ten times Luna's measured cost per run, lands 0.78 points &lt;em&gt;below&lt;/em&gt; its cheaper sibling&lt;/strong&gt;, that gap is inside the noise band, so the honest reading is a tie, and a tie is the damning version: on this workload, paying 10× for the bigger variant bought nothing we could detect. The caching column of the economy matters more than the sticker: Nova Lite Gen 1 at $0.0013/run is the budget surprise, and the no-cache tier is uniformly mispriced for agent work. And the two models we tuned for hold ranks 1 and 3, which is the tuning bias made visible, not a neutral ranking. What we would &lt;em&gt;not&lt;/em&gt; defend: reading any two adjacent rows as meaningfully different, with three runs each, neighbours can swap on a single one going the other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where GPT-6 Luna lands.&lt;/strong&gt; It is not in the table because it did not exist on the day the table was measured, and a row from another day would break the one thing the table promises. On the same 36 scenarios, measured on September 23, it scored &lt;strong&gt;90.12%&lt;/strong&gt;. Two of those scenarios are the stale-data cells described above, where it answered correctly and was marked wrong. The rest of the gap is the misses named above, plus an email lookup where it searched with the wrong prefix, found nothing and stopped. Treat it as indicative: in its predecessor's range, not above it, at less than half the price.&lt;/p&gt;

&lt;p&gt;One more thing the ladder run measured: our own suite, again. The three autocomplete scenarios initially scored &lt;strong&gt;0% for all four no-cache models (36 runs of zero)&lt;/strong&gt; and every one was rejected before the model generated a token: we were sending a request field those models don't accept. Same bug class we &lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark" rel="noopener noreferrer"&gt;published about before&lt;/a&gt;, new field. Those 36 zeros measured our bug, not the models, so they are excluded from every number above, which raised those four scores by about 4 points each.&lt;/p&gt;

&lt;p&gt;Then we fixed the builder and re-ran exactly those cells. &lt;strong&gt;DeepSeek V3.2 (which the broken suite had at 0%) scored 100% on all three autocomplete scenarios.&lt;/strong&gt; The other three landed mid-range. Had we published the first run uncorrected, we'd have reported a model as incapable of autocomplete when it is, measurably, the best of the four at it. A benchmark that won't audit itself will happily rank vendors on its own bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Autocomplete: both Lunas can do it now
&lt;/h2&gt;

&lt;p&gt;When this post first ran, the GPT-5.6 models could not drive DynoTable's inline autocomplete at all: that path spoke only to Converse, and GPT-5.6 lived on Mantle. We moved autocomplete onto the same model layer the agent uses and measured again. Autocomplete here is the gray ghost text in the query editor: given the statement so far, the table schema and some stored values, complete the line.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Autocomplete score&lt;/th&gt;
&lt;th&gt;Median time per completion&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;98.48%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;698 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPT-6 Luna&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96.97%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;482 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Nova 2 Lite&lt;/td&gt;
&lt;td&gt;96.21%&lt;/td&gt;
&lt;td&gt;525 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nova Micro&lt;/td&gt;
&lt;td&gt;81.82%&lt;/td&gt;
&lt;td&gt;345 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is 22 autocomplete cases, three runs each, measured on September 23 with reasoning off. GPT-5.6 Luna scored 96.08% on the 17 cases the suite had in August; it has not been re-run on the five added since, so it gets no row here.&lt;/p&gt;

&lt;p&gt;Getting GPT-6 Luna to that number taught us two things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It would rather say nothing than guess, and that is correct.&lt;/strong&gt; On its first run it left 7 of 18 cases blank on every try. Six of the seven were cases where the right completion was a table or column name the prompt never showed. Our prompt already said "emit nothing rather than guess", and GPT-6 Luna was the first model to take it literally. We decided it was right: autocomplete should never invent an identifier you did not write and your schema does not contain. The scoring changed to accept an empty completion there, and the prompt stayed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It also refused to pick from values it &lt;em&gt;was&lt;/em&gt; shown.&lt;/strong&gt; Given a column and its four stored values, it still emitted nothing, reading "pick one of these" as the guess it had been told not to make. One sentence fixed it: choosing a candidate the statement or the schema actually shows is not a guess. Every model went up or stayed flat on the next run (Nova Micro by 8.6 points), and GPT-6 Luna's value-completion cases went to 100%.&lt;/p&gt;

&lt;p&gt;We tried the obvious lever first. Raising GPT-6 Luna's reasoning effort to low or medium moved its score by half a point and roughly doubled its latency, so autocomplete runs with reasoning off. The whole final autocomplete run, four models on 22 cases, cost five cents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What transfers if you're picking a cheap agent model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark on your workload, not on leaderboards.&lt;/strong&gt; Luna's public scores and our agent scores rank models differently. The only number that predicts your bill and your failure rate is one measured on your own loops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new generation is not automatically a better agent.&lt;/strong&gt; GPT-6 Luna tied GPT-5.6 Luna on our work and halved the cost. That is a good trade, but it is a price story, not a capability one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price the loop, not the token.&lt;/strong&gt; Cache behavior differs per model family: read rates, write premiums, and whether caching is implicit or needs breakpoints. Two models with similar stickers can differ 5× per call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model being 2 points behind may not matter, where it loses does.&lt;/strong&gt; Read the failing behaviors, not the aggregate. A wrong refusal is recoverable; a silent wrong answer is not. A blank autocomplete is better than an invented column name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit your test data, not just your scorers.&lt;/strong&gt; Two of GPT-6 Luna's "failures" were answers checked against data we had since replaced. Expected values drift like everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record what every call costs, from day one.&lt;/strong&gt; Every number here exists because we kept that record. Estimating from published prices would have missed the caching story entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GPT-6 Luna is in DynoTable's default model set, and GPT-5.6 Luna remains a verified, selectable model. Bring your own &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark#bedrock" rel="noopener noreferrer"&gt;Bedrock&lt;/a&gt; credentials and pick either in the model picker. Claude Haiku 4.5 and Nova 2 Lite remain the models we tune against, and the gap is real. But if your bill is the constraint, two or three points is a very cheap trade.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark" rel="noopener noreferrer"&gt;Download DynoTable&lt;/a&gt; · &lt;a href="https://dynotable.com/docs/dynamodb-ai-chat?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark" rel="noopener noreferrer"&gt;How the AI agent works&lt;/a&gt; · &lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=gpt-5-6-luna-benchmark" rel="noopener noreferrer"&gt;The eval story behind our scorers&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;On one &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=tool-validators-evals-cheap-models#eval" rel="noopener noreferrer"&gt;Eval&lt;/a&gt; scenario (a join that needs the composite-key table as the&lt;br&gt;
base), Nova Lite scored &lt;strong&gt;0%&lt;/strong&gt;. We didn't switch models, didn't add few-shot&lt;br&gt;
examples, didn't fine-tune. We rewrote one validator error message to say&lt;br&gt;
exactly what to do instead. It went to &lt;strong&gt;100%&lt;/strong&gt;, flipping the join on the&lt;br&gt;
very next step.&lt;/p&gt;

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

&lt;p&gt;The rest of the story is the validators that sit&lt;br&gt;
around every tool call, the things we deliberately do &lt;em&gt;not&lt;/em&gt; validate, and&lt;br&gt;
the eval suite that decides every change. If you're building an&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=tool-validators-evals-cheap-models#ai-agent" rel="noopener noreferrer"&gt;agent&lt;/a&gt; on a budget model — over DynamoDB or anything else —&lt;br&gt;
most of it transfers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why cheap models fail at DynamoDB specifically
&lt;/h2&gt;

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

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

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

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

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

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

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

&lt;/div&gt;





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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;The caps are intentionally far below what the context could hold. The goal&lt;br&gt;
is behavioral: &lt;em&gt;force the model to hand the user a real table view or an&lt;br&gt;
exact aggregate, never to enumerate rows into chat.&lt;/em&gt; The recovery hint&lt;br&gt;
names the exact tools, so the cap teaches the routing at the moment the&lt;br&gt;
model needs it. The same envelope is mirrored byte-for-byte in the eval&lt;br&gt;
suite, and a regression scenario pins &lt;code&gt;truncated: true&lt;/code&gt; against a&lt;br&gt;
10,000-item table so the cap can never silently disappear.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7er65xf2zr52ehjryz6b.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7er65xf2zr52ehjryz6b.webp" alt="Instead of enumerating rows into chat, the agent emits a chip you click to open the result as a real DynoTable tab." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;And when you want determinism instead of generation, the query you'll&lt;br&gt;
commit to code, skip the model entirely: the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=tool-validators-evals-cheap-models" rel="noopener noreferrer"&gt;Expression Builder&lt;/a&gt; assembles the&lt;br&gt;
exact request by hand.&lt;/p&gt;

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

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

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

&lt;blockquote&gt;
&lt;p&gt;Diagram: &lt;a href="https://dynotable.com/blog/local-sql-engine-for-dynamodb?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=local-sql-engine-for-dynamodb" rel="noopener noreferrer"&gt;view on dynotable.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;The honest constraint we kept: &lt;strong&gt;DynamoDB's access-pattern physics still&lt;br&gt;
apply.&lt;/strong&gt; A &lt;code&gt;JOIN&lt;/code&gt;'s to-side must be a primary key or a &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=local-sql-engine-for-dynamodb#gsi" rel="noopener noreferrer"&gt;GSI&lt;/a&gt;&lt;br&gt;
partition key, the engine resolves joins by key lookup against the&lt;br&gt;
stream, never by cross-product scan. If you need arbitrary joins on&lt;br&gt;
non-key attributes, that's a &lt;a href="https://dynotable.com/learn/dynamodb-join?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=local-sql-engine-for-dynamodb" rel="noopener noreferrer"&gt;modeling conversation&lt;/a&gt;,&lt;br&gt;
not a query-engine feature.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftqbdzcoid4t8eucnyrml.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftqbdzcoid4t8eucnyrml.webp" alt="DynoTable's SQL Workbench running a multi-table JOIN over DynamoDB tables, with the joined result grid below." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The parser that lied to us
&lt;/h2&gt;

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>Why a database GUI should never write directly</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Fri, 17 Jul 2026 20:29:59 +0000</pubDate>
      <link>https://dev.to/dynotable/why-a-database-gui-should-never-write-directly-535p</link>
      <guid>https://dev.to/dynotable/why-a-database-gui-should-never-write-directly-535p</guid>
      <description>&lt;p&gt;Every database GUI has a moment where a click turns into a write against&lt;br&gt;
production. We decided that moment shouldn't be the default. In DynoTable&lt;br&gt;
an item edit, a staged delete and an &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-gui-staged-writes#ai-agent" rel="noopener noreferrer"&gt;AI agent&lt;/a&gt;'s change do not&lt;br&gt;
touch DynamoDB directly: each lands in a &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-gui-staged-writes#staging-area" rel="noopener noreferrer"&gt;staging area&lt;/a&gt; as&lt;br&gt;
a reviewable per-attribute diff, and ships only when a human commits it.&lt;br&gt;
Two deliberate escape hatches still write straight through, and the&lt;br&gt;
&lt;a href="https://dynotable.com/docs/staging?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-gui-staged-writes" rel="noopener noreferrer"&gt;staging docs&lt;/a&gt; name them.&lt;/p&gt;

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

&lt;p&gt;Edits accumulate in a local SQLite-backed stage and render as diff cards (old value, new value, per attribute) in a side panel. Rows tint in the&lt;br&gt;
grid so staged state is visible from the data, not just the panel. Commit&lt;br&gt;
ships the staged set as DynamoDB &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-gui-staged-writes#transaction" rel="noopener noreferrer"&gt;transactions&lt;/a&gt;, chunked to&lt;br&gt;
the service's limits (100 items per transaction, with byte budgets per&lt;br&gt;
operation and per request), sequentially, with a 30-second timeout per&lt;br&gt;
chunk.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjt152cafcesdaaqt1c2w.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjt152cafcesdaaqt1c2w.webp" alt="DynoTable's staging panel showing pending DynamoDB changes as per-attribute diff cards, with per-row and bulk commit." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dynotable.com/docs/staging?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-gui-staged-writes" rel="noopener noreferrer"&gt;staging docs&lt;/a&gt; cover the daily workflow. What they don't&lt;br&gt;
cover is what the stage is &lt;em&gt;keyed by&lt;/em&gt;, which turned out to be the most&lt;br&gt;
consequential decision in the system.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keyed to the table, not the tab
&lt;/h2&gt;

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

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

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

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

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

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

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

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

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

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

&lt;blockquote&gt;
&lt;p&gt;Diagram: &lt;a href="https://dynotable.com/blog/dynamodb-gui-staged-writes?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-gui-staged-writes" rel="noopener noreferrer"&gt;view on dynotable.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The refactor that deleted a subsystem
&lt;/h2&gt;

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

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

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

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

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

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

&lt;/div&gt;



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

&lt;p&gt;We wrote about &lt;a href="https://dynotable.com/blog/tool-validators-evals-cheap-models?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=dynamodb-gui-staged-writes" rel="noopener noreferrer"&gt;making the agent's queries reliable&lt;/a&gt;;&lt;br&gt;
staging is the other half, making its &lt;em&gt;writes&lt;/em&gt; boring.&lt;/p&gt;

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

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

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

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>What it took to build a safe DynamoDB MCP server</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Thu, 16 Jul 2026 09:26:10 +0000</pubDate>
      <link>https://dev.to/dynotable/what-it-took-to-build-a-safe-dynamodb-mcp-server-p5d</link>
      <guid>https://dev.to/dynotable/what-it-took-to-build-a-safe-dynamodb-mcp-server-p5d</guid>
      <description>&lt;p&gt;The &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server#mcp" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; makes it trivial to hand an&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server#ai-agent" rel="noopener noreferrer"&gt;AI agent&lt;/a&gt; your database. That's the problem. Most DynamoDB MCP servers are a thin process&lt;br&gt;
holding your &lt;strong&gt;raw AWS credentials&lt;/strong&gt; with a write path and no review step.&lt;br&gt;
We've written about &lt;a href="https://dynotable.com/learn/dynamodb-mcp-server?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;why that pattern is risky and how to choose&lt;br&gt;
safely&lt;/a&gt; from the user's side.&lt;/p&gt;

&lt;p&gt;This post covers what it actually took to build the safe&lt;br&gt;
version. &lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;DynoTable&lt;/a&gt; is a desktop DynamoDB client whose&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-mcp?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; exposes its gated AI toolkit to external agents (Claude Code, Cursor, Codex) without the agent ever touching AWS&lt;br&gt;
credentials. Getting there meant shipping an &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server#oauth" rel="noopener noreferrer"&gt;OAuth 2.1&lt;/a&gt;&lt;br&gt;
authorization server &lt;em&gt;inside&lt;/em&gt; an Electron app, designing an identity type that makes&lt;br&gt;
credential mix-ups unrepresentable, and deciding, more than once, that the&lt;br&gt;
convenient default was the wrong one.&lt;/p&gt;

&lt;p&gt;If you're building an MCP server around anything sensitive, these are the&lt;br&gt;
decisions you'll meet too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loopback HTTP is not automatically safe
&lt;/h2&gt;

&lt;p&gt;The server binds &lt;code&gt;127.0.0.1&lt;/code&gt;, which sounds safe and isn't sufficient. Any&lt;br&gt;
web page you visit can attempt requests toward localhost, and DNS&lt;br&gt;
rebinding lets a hostile page dress those up with a plausible &lt;code&gt;Host&lt;/code&gt;. So&lt;br&gt;
the first layer is an origin guard: reject any request carrying a real&lt;br&gt;
web-page &lt;code&gt;Origin&lt;/code&gt;, reject non-loopback &lt;code&gt;Host&lt;/code&gt; headers, but &lt;strong&gt;allow&lt;br&gt;
requests with no Origin at all&lt;/strong&gt;, because legitimate CLI clients like&lt;br&gt;
Claude Code don't send one.&lt;/p&gt;

&lt;p&gt;That last clause is the honest part: an origin guard only blocks the&lt;br&gt;
browser attack class. It cannot authenticate anyone. The bearer token is&lt;br&gt;
the real gate, which raises the question of where tokens come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  We shipped an OAuth 2.1 authorization server inside the app
&lt;/h2&gt;

&lt;p&gt;MCP's remote-server auth story is OAuth 2.1, and for a local desktop&lt;br&gt;
server there is no upstream identity provider to lean on. So the app &lt;em&gt;is&lt;/em&gt;&lt;br&gt;
the authorization server: dynamic client registration, &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server#pkce" rel="noopener noreferrer"&gt;PKCE&lt;/a&gt; (S256),&lt;br&gt;
authorization codes, refresh tokens, revocation, and the RFC 8414 / RFC&lt;br&gt;
9728 metadata documents that let clients discover it all.&lt;/p&gt;

&lt;p&gt;Rules we set early and kept:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't hand-roll the protocol.&lt;/strong&gt; The MCP SDK ships the full AS surface
as a router; we implement only the storage/policy provider beneath it.
One consequence we accepted: our MCP framework speaks one HTTP stack
internally and the SDK router speaks another, so the AS runs as a second
loopback listener, with the resource server pointing clients at it via
protected-resource metadata. Two listeners is a scoped, explicit cost;
re-deriving PKCE and token grammar by hand is an unbounded one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirects are loopback-only&lt;/strong&gt; (per RFC 8252 for native apps), and an
invalid redirect never gets an error redirect, it gets a direct 400,
so an attacker's URI is never a destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization codes are single-use&lt;/strong&gt;, a replayed code is an invalid
grant, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh can narrow scope but never widen it&lt;/strong&gt;, and it re-issues the
same identity payload verbatim, a refreshed token can't quietly
acquire a different region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation is re-checked on every request&lt;/strong&gt;, not cached at connect, clicking "Revoke" in Settings kills the client's next call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Development builds use a simpler auth path, built so it cannot exist in a&lt;br&gt;
release binary, an unauthenticated full-scope token in shipped software&lt;br&gt;
would be a local backdoor behind an origin guard that deliberately admits&lt;br&gt;
no-Origin requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  One connection, one AWS identity — carried whole, never guessed
&lt;/h2&gt;

&lt;p&gt;An external agent connects to &lt;em&gt;a profile&lt;/em&gt;, not&lt;br&gt;
to "DynoTable". Each connection is bound to one AWS profile and one&lt;br&gt;
region, backed by its own non-shared DynamoDB client, so two agents on&lt;br&gt;
different profiles can't bleed into each other.&lt;/p&gt;

&lt;p&gt;The binding started life as loose fields, &lt;code&gt;awsProfile&lt;/code&gt;, &lt;code&gt;region&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;profileId&lt;/code&gt;, an optional local port, re-declared across every record on&lt;br&gt;
the path. It's now &lt;strong&gt;one discriminated-union identity type&lt;/strong&gt; with two&lt;br&gt;
arms: &lt;code&gt;online&lt;/code&gt; (profile + region + id) and &lt;code&gt;offline&lt;/code&gt; (local port + id,&lt;br&gt;
for &lt;a href="https://dynotable.com/learn/dynamodb-local?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;DynamoDB Local&lt;/a&gt; profiles). Offline-ness is&lt;br&gt;
the discriminant itself, so an online identity without credentials or an&lt;br&gt;
offline one carrying a region is a &lt;em&gt;type error&lt;/em&gt;, not a runtime surprise.&lt;br&gt;
That one type travels intact through consent → token → grant → every&lt;br&gt;
request.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram: &lt;a href="https://dynotable.com/blog/building-dynamodb-mcp-server?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;view on dynotable.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two consequences we consider load-bearing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Region is frozen at consent.&lt;/strong&gt; The user approves "profile X in
region Y", and that tuple is pinned on the token, surviving refresh,
never re-resolved from mutable state on the hot path. Editing the
profile's region requires re-consent. The alternative, resolving
region per-request or falling back to a default, is how an agent
quietly ends up querying &lt;code&gt;us-east-1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Malformed tokens are a 401, never a coercion.&lt;/strong&gt; If a token's identity
claims don't parse into a valid arm, the request is unauthorized. No
&lt;code&gt;'default'&lt;/code&gt; profile, no default region, no empty string. Every place
the old code would have guessed is now a hard failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Consent is a product surface, not a dialog box
&lt;/h2&gt;

&lt;p&gt;The first consent prompt was the platform-native message box. It froze&lt;br&gt;
the Electron main process while open, couldn't match the app's design&lt;br&gt;
system, and couldn't even be screenshotted for docs. It's now an in-app&lt;br&gt;
modal: the connecting client's name, a &lt;strong&gt;profile picker&lt;/strong&gt; over exactly the&lt;br&gt;
profiles the user chose to expose, and three scopes offered&lt;br&gt;
narrowest-first, read-only, read + stage, full. Expired or duplicate&lt;br&gt;
consent requests settle as denied rather than hanging the OAuth flow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpfeysr7xcg8asixyw9y7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpfeysr7xcg8asixyw9y7.webp" alt="The in-app MCP consent modal: the connecting client, the profile picker, and the three narrowest-first scopes, read-only, read + stage, and full access." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The picker has a small trust boundary of its own: a client may &lt;em&gt;hint&lt;/em&gt; a&lt;br&gt;
profile in the authorize URL, but the grant is minted from what the user&lt;br&gt;
actually selected, validated against the snapshot they were shown, a&lt;br&gt;
forged hint can't bind a profile that was never on screen.&lt;/p&gt;

&lt;p&gt;We're equally explicit about what consent does &lt;strong&gt;not&lt;/strong&gt; give you:&lt;br&gt;
authorization is scope-only, approved once per connection. The in-app&lt;br&gt;
assistant's per-call permission prompts do not apply to MCP traffic, a&lt;br&gt;
leaked full-scope token can write (into staging) without a human seeing&lt;br&gt;
each call. Short token lifetimes, per-client revocation, an always-on&lt;br&gt;
audit trail marking every MCP-originated action, and read-only as the&lt;br&gt;
default offer are the mitigations. Writing that down as a residual risk&lt;br&gt;
was more useful than pretending the consent modal closed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails for headless work
&lt;/h2&gt;

&lt;p&gt;An external agent has no renderer, no confirmation popups, no user&lt;br&gt;
watching. Everything interactive needed a headless equivalent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live license clamping, per call.&lt;/strong&gt; The grant stores the scope the
user approved; the &lt;em&gt;effective&lt;/em&gt; scope is derived on every tool call from
the app's current license state, a read-only license clamps a full
grant down, a signed-out state rejects with 401 before any token
branch (so the dev token can't bypass it), and a mid-session
reactivation re-widens without re-consent. One deliberate asymmetry:
in-app export stays available under a read-only license (your data is
your data), but headless bulk export over MCP is clamped off, an
interactive portability right and an agent-driven egress channel are
different trust surfaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan budgets instead of confirmation popups.&lt;/strong&gt; In the app, a
whole-table read asks first. Headless, an items/bytes/duration budget
bounds every scan, and SQL over tables streams through a disk-backed
engine rather than an in-memory cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disconnect aborts honestly.&lt;/strong&gt; If the client drops mid-scan, the
server aborts at the next page boundary (never mid-statement) and
disposes the temporary state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server#mfa" rel="noopener noreferrer"&gt;MFA&lt;/a&gt; completes over the wire.&lt;/strong&gt; When AWS credentials need an MFA code
and the client supports MCP elicitation, the server elicits the
6-digit code &lt;em&gt;through the agent's own UI&lt;/em&gt;. Claude Code prompts the
human, and retries. SSO is deliberately excluded from this path: it
needs a browser device flow, and pretending otherwise would just be a
worse error message. Concurrent calls needing the same credentials
coalesce into one prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is exposing a database over MCP safe at all?
&lt;/h2&gt;

&lt;p&gt;Our honest answer: only if the server is designed around the assumption&lt;br&gt;
that the agent is confused at best and adversarial at worst.&lt;br&gt;
&lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server#prompt-injection" rel="noopener noreferrer"&gt;Prompt injection&lt;/a&gt; makes an agent's &lt;em&gt;intent&lt;/em&gt; untrustworthy, which is why the&lt;br&gt;
architecture never relies on the agent behaving: credentials stay in the&lt;br&gt;
app, writes only ever land in a &lt;a href="https://dynotable.com/docs/staging?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;reviewable staging&lt;br&gt;
area&lt;/a&gt;, scopes clamp what a call can do regardless of what&lt;br&gt;
the model asks for, and every action is audited. The&lt;br&gt;
&lt;a href="https://dynotable.com/learn/dynamodb-mcp-server?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;connect-side guide&lt;/a&gt; covers what that means&lt;br&gt;
in practice for each client.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to demand from any MCP server that touches your database
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Where do the credentials live, in the agent's process, or behind the
server?&lt;/li&gt;
&lt;li&gt;Is there per-connection consent with real scopes, or does one config
file grant everything?&lt;/li&gt;
&lt;li&gt;Can a write execute without a human review step?&lt;/li&gt;
&lt;li&gt;Is the identity (profile, region) pinned at consent, or resolved from
defaults at call time?&lt;/li&gt;
&lt;li&gt;What happens on a malformed or revoked token, hard failure, or a
fallback?&lt;/li&gt;
&lt;li&gt;Is there an audit trail that distinguishes agent-originated actions?&lt;/li&gt;
&lt;li&gt;What bounds a runaway scan, and what happens when the client
disconnects mid-operation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a server can't answer those, the convenient integration is the risky&lt;br&gt;
one. For sizing what an unbounded scan would actually cost you, the&lt;br&gt;
&lt;a href="https://dynotable.com/tools/dynamodb-pricing-calculator?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;pricing calculator&lt;/a&gt; is sobering, and if you'd rather see the safe version running than read about it,&lt;br&gt;
&lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=building-dynamodb-mcp-server" rel="noopener noreferrer"&gt;download DynoTable&lt;/a&gt;, flip on the MCP server, and connect a&lt;br&gt;
client at read-only scope in under a minute.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>database</category>
      <category>nosql</category>
    </item>
    <item>
      <title>The Best DynamoDB GUI Clients in 2026</title>
      <dc:creator>DynoTable</dc:creator>
      <pubDate>Sat, 04 Jul 2026 10:38:10 +0000</pubDate>
      <link>https://dev.to/dynotable/the-best-dynamodb-gui-clients-in-2026-1791</link>
      <guid>https://dev.to/dynotable/the-best-dynamodb-gui-clients-in-2026-1791</guid>
      <description>&lt;p&gt;There is no single "best" DynamoDB GUI; there are tools built for different jobs. This roundup covers the clients that actually matter in 2026, with every pricing and platform claim re-checked against the vendor's own page on 2026-08-06 (the one exception is dated inline). One of the tools is ours; we say so where it matters and list what it does not do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the best DynamoDB GUI?
&lt;/h2&gt;

&lt;p&gt;It depends on the job: &lt;strong&gt;NoSQL Workbench&lt;/strong&gt; is the best free client for designing&lt;br&gt;
tables, &lt;strong&gt;dynamodb-admin&lt;/strong&gt; for browsing a local Docker table, &lt;strong&gt;Dynobase&lt;/strong&gt; the&lt;br&gt;
most feature-complete paid incumbent, and &lt;strong&gt;DynoTable&lt;/strong&gt; the pick when your&lt;br&gt;
questions are SQL-shaped — &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, and aggregates that PartiQL-only&lt;br&gt;
tools can't run. Every claim below was re-checked against vendor pages on&lt;br&gt;
2026-08-06.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best free DynamoDB GUI:&lt;/strong&gt; NoSQL Workbench for designing tables, dynamodb-admin for peeking at a local table, DocKit for a free open-source live-data client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best paid daily driver:&lt;/strong&gt; Dynobase remains the most feature-complete incumbent; DynoTable if your questions are SQL-shaped (&lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, aggregates that &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients#partiql" rel="noopener noreferrer"&gt;PartiQL&lt;/a&gt; tools can't run).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing moved this year:&lt;/strong&gt; Dynomate dropped its $199 one-time license for $10–$29/mo subscriptions (checked 2026-08-06). Re-verify prices before budgeting; ours were all checked on that date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker/local:&lt;/strong&gt; dynamodb-admin against the &lt;code&gt;amazon/dynamodb-local&lt;/code&gt; container is still the standard answer, and it's free.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Every DynamoDB GUI compared
&lt;/h2&gt;

&lt;p&gt;Prices and platforms verified against each vendor's own page on 2026-08-06. A dash means not offered or not verifiable from the vendor's docs. (TablePlus's price and platforms are from that date too; only its DynamoDB-connector capabilities are the older 2026-06-10 check, flagged in its section below.)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Platforms&lt;/th&gt;
&lt;th&gt;Visual query builder&lt;/th&gt;
&lt;th&gt;Live data ops&lt;/th&gt;
&lt;th&gt;Modeling&lt;/th&gt;
&lt;th&gt;JOIN / GROUP BY / aggregates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NoSQL Workbench&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;macOS / Win / Linux&lt;/td&gt;
&lt;td&gt;Operation builder&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dynobase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$9/mo billed annually, or $199 once&lt;/td&gt;
&lt;td&gt;macOS / Win / Linux&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;No (PartiQL only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dynomate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free tier; $10–$29/mo billed annually&lt;/td&gt;
&lt;td&gt;macOS / Win / Linux&lt;/td&gt;
&lt;td&gt;Request editor&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (SQL over exports, beta)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DbVisualizer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free (limited); Pro $199 first yr, $89 renewal&lt;/td&gt;
&lt;td&gt;macOS / Win / Linux&lt;/td&gt;
&lt;td&gt;Yes (generates SQL)&lt;/td&gt;
&lt;td&gt;Solid&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (PartiQL only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DocKit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free (Apache-2.0)&lt;/td&gt;
&lt;td&gt;macOS / Win / Linux&lt;/td&gt;
&lt;td&gt;Yes (13+ operators)&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (PartiQL only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dynamodb-admin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free (MIT)&lt;/td&gt;
&lt;td&gt;Any (Node)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Local only&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DynamoIt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free (GPL-3.0)&lt;/td&gt;
&lt;td&gt;Any (JVM)&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TablePlus&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$99–$129 once (1 yr of updates)&lt;/td&gt;
&lt;td&gt;macOS / Win / Linux&lt;/td&gt;
&lt;td&gt;Filter only&lt;/td&gt;
&lt;td&gt;Beta (view/filter)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DataGrip&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Paid IDE&lt;/td&gt;
&lt;td&gt;macOS / Win / Linux&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Viewer + PartiQL&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (PartiQL only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DynoTable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free read-only; from $9/mo billed annually&lt;/td&gt;
&lt;td&gt;macOS* / Win / Linux&lt;/td&gt;
&lt;td&gt;Yes (index picker)&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (SQL Workbench)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* DynoTable's macOS build is Apple Silicon only. Its free plan requires an account and is read-only; see &lt;a href="https://dynotable.com/pricing?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to choose a DynamoDB UI
&lt;/h2&gt;

&lt;p&gt;Pick based on the job you actually do most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live data ops:&lt;/strong&gt; browse, filter, and edit items in real tables, fast, without the AWS Console. This is the daily-driver case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data modeling:&lt;/strong&gt; design tables, sort keys, and &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients#gsi" rel="noopener noreferrer"&gt;GSIs&lt;/a&gt; around your access patterns before you write code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics-shaped questions:&lt;/strong&gt; JOINs, &lt;code&gt;GROUP BY&lt;/code&gt;, aggregates. DynamoDB's own query surface does not do these (more below), so the tool has to compile around the limitation.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your main job&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Design tables / GSIs before coding&lt;/td&gt;
&lt;td&gt;NoSQL Workbench&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browse &amp;amp; edit live data daily&lt;/td&gt;
&lt;td&gt;Dynobase or DynoTable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git-shareable saved query collections&lt;/td&gt;
&lt;td&gt;Dynomate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One GUI for DynamoDB + Elasticsearch / MongoDB&lt;/td&gt;
&lt;td&gt;DocKit or DbVisualizer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Just see what's in a &lt;em&gt;local&lt;/em&gt; table&lt;/td&gt;
&lt;td&gt;dynamodb-admin / DynamoIt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already live in a JetBrains IDE&lt;/td&gt;
&lt;td&gt;DataGrip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JOIN / &lt;code&gt;GROUP BY&lt;/code&gt; / aggregates over your data&lt;/td&gt;
&lt;td&gt;DynoTable (SQL Workbench)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;Most teams end up with two tools, not one: a modeler for design time and a data-ops client for everything after. That split is normal; don't expect a single GUI to be excellent at both.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  AWS NoSQL Workbench
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free · macOS / Windows / Linux · modeling-first&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/dynamodb/nosql-workbench/" rel="noopener noreferrer"&gt;NoSQL Workbench&lt;/a&gt; is AWS's own "cross-platform, client-side GUI application" and it costs nothing. Its data modeler designs tables and GSIs from scratch or from an imported model, lets you "visualize your access patterns as real DynamoDB operations" against sample data, then commits the model to DynamoDB Local or a real account.&lt;/p&gt;

&lt;p&gt;The operation builder is the live half: "view, explore, and query live datasets", with projection and condition expression support, sample-code generation in multiple languages, and table cloning between accounts, regions, and DynamoDB Local. The download includes DynamoDB Local, so offline modeling works out of the box.&lt;/p&gt;

&lt;p&gt;It's the best free way to &lt;em&gt;design&lt;/em&gt; a table. Treat it as a development and modeling tool — browsing or editing real data all day in it is slow going. Reach for it when you're modeling your &lt;a href="https://dynotable.com/learn/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;, not when you're debugging a row at 2am. Our &lt;a href="https://dynotable.com/compare/nosql-workbench-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;NoSQL Workbench alternative&lt;/a&gt; page covers the day-to-day gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; the free modeling tool every DynamoDB team should have installed; pair it with a data-ops client.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dynobase
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;$9/mo billed annually ($108) or $199 one-time · 7-day trial · macOS / Windows / Linux&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Dynobase is the established paid desktop client and still the most feature-complete commercial option. As of 2026-08-06 its pricing page lists a Solo plan at $9/month billed annually ($108), a one-time $199 lifetime license (struck from $249), and a Team plan at $79/month billed annually, all behind a 7-day trial with no credit card.&lt;/p&gt;

&lt;p&gt;It covers fast exploration, inline editing, a visual query builder (its headline feature, and the axis every rival gets measured on), import/export in CSV or JSON, code generation for JS/TS, Rust, Go, and Python, and local DynamoDB including Docker distributions.&lt;/p&gt;

&lt;p&gt;Two things to weigh. It's closed-source, and its SQL surface is AWS &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients#partiql" rel="noopener noreferrer"&gt;PartiQL&lt;/a&gt; pass-through, so it inherits PartiQL's limits: no cross-table &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, or aggregates. And its changelog lists "Dynobase 2.5.0 Beta" at the top with no release dates (checked 2026-08-06); several rivals have built entire comparison pages on its release cadence, so judge maintenance for yourself before a lifetime purchase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; the safe, broad commercial pick if the license cost is fine. Our &lt;a href="https://dynotable.com/compare/dynobase-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;Dynobase alternative&lt;/a&gt; page covers where DynoTable differs, chiefly the &lt;a href="https://dynotable.com/docs/dynamodb-glossary?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients#workbench" rel="noopener noreferrer"&gt;SQL Workbench&lt;/a&gt; and EUR/EU-tax billing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dynomate
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free tier + $10–$29/mo billed annually · 14-day trial · macOS / Windows / Linux&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Dynomate grew popular as a Dynobase alternative, and its distinctive feature is a &lt;strong&gt;request-collection workflow&lt;/strong&gt;: save queries, chain one request's results into the next with environment variables, organize them in folders, and share the collections through Git. It also does AWS SSO and MFA sign-in inside the app, multi-profile and multi-region tabs, DynamoDB Local, and CSV/JSON export.&lt;/p&gt;

&lt;p&gt;Its pricing changed in 2026. When we checked on 2026-06-10 it sold a $199 one-time license; as of 2026-08-06 the pricing page lists a free Community tier and subscriptions at $10, $18, and $29 per month billed annually, with a 14-day trial and a 30-day money-back guarantee.&lt;/p&gt;

&lt;p&gt;One honest detail its "SQL console" label hides: it queries DynamoDB &lt;em&gt;table exports&lt;/em&gt; with SQL (marked Beta), not your live tables. For Git-friendly, reviewable query collections it's a strong pick; our &lt;a href="https://dynotable.com/compare/dynomate-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;Dynomate alternative&lt;/a&gt; page covers where DynoTable differs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; the pick if a shared, versioned library of saved requests is the feature you're shopping for.&lt;/p&gt;
&lt;h2&gt;
  
  
  DbVisualizer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free edition (limited) · Pro $199/user first year, $89 renewal · 21-day trial · macOS / Windows / Linux&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DbVisualizer is the veteran multi-database SQL client, and its DynamoDB support is real: it browses tables, partition and sort keys, and local and global indexes, edits items including maps and sets with mixed content, and runs PartiQL through its SQL Commander with a visual query builder that generates the statements for you.&lt;/p&gt;

&lt;p&gt;Pricing splits into a free edition with a limited feature set and a Pro tier at $199 per user for the first year ($89 renewal), with a 21-day trial and no credit card. If your week spans Postgres, MySQL, &lt;em&gt;and&lt;/em&gt; DynamoDB, one tool for all of them is a fair trade.&lt;/p&gt;

&lt;p&gt;The trade-off is the same one every generalist makes: DynamoDB is one of dozens of supported databases, the querying is single-table PartiQL, and there's no DynamoDB-native modeling or cost awareness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; worth a look if DynamoDB is a side database in a relational-heavy week, not your main store.&lt;/p&gt;
&lt;h2&gt;
  
  
  DocKit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free (Apache-2.0) · open-source · multi-datastore · macOS / Windows / Linux&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DocKit is a free, open-source desktop client built on Tauri (Rust), which keeps the install small, and it spans five datastores: Elasticsearch, OpenSearch, DynamoDB, MongoDB, and EasySearch. For DynamoDB it has a query builder with primary-key filtering and 13+ operators, plus a PartiQL editor with autocomplete and inline item CRUD.&lt;/p&gt;

&lt;p&gt;Its "Agentic Data Studio" AI assistant is bring-your-own-key across OpenAI, Anthropic, DeepSeek, Ollama, and LM Studio, so your data and keys stay on your machine.&lt;/p&gt;

&lt;p&gt;Its breadth is also its trade-off: DocKit is a generalist across five stores rather than a DynamoDB-native client, and its DynamoDB querying is single-table PartiQL. No cross-table &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, or aggregates, and no modeling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; the best free option if your stack is broader than DynamoDB and price matters.&lt;/p&gt;
&lt;h2&gt;
  
  
  dynamodb-admin and DynamoIt
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free / open-source · local-focused&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For local development there are good free options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dynamodb-admin&lt;/strong&gt; is a small MIT-licensed web GUI for DynamoDB Local, dynalite, and LocalStack. Install it globally and point it at your local endpoint:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  pnpm add &lt;span class="nt"&gt;-g&lt;/span&gt; dynamodb-admin
  dynamodb-admin &lt;span class="nt"&gt;--dynamo-endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;By default it sets dummy credentials (&lt;code&gt;key&lt;/code&gt;/&lt;code&gt;secret&lt;/code&gt;, region &lt;code&gt;us-east-1&lt;/code&gt;) so it connects straight to a local endpoint, which makes it the standard answer for "I just need to see what's in my local table." For a deeper walkthrough of endpoints and dummy creds, see &lt;a href="https://dynotable.com/learn/dynamodb-local?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;connecting to DynamoDB Local&lt;/a&gt;; for how it stacks up as a daily tool, see the &lt;a href="https://dynotable.com/compare/dynamodb-admin-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;dynamodb-admin alternative&lt;/a&gt; page.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DynamoIt&lt;/strong&gt; is a free, GPL-3.0 JavaFX desktop viewer that reads your AWS CLI profiles for quick browse/edit work with no login step. It auto-picks scan vs query, paginates, and supports local DynamoDB; the README notes you need at least Java 17 (and Maven) to build and run it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; both are great for local and light use and cost nothing; neither aims to be a production workbench.&lt;/p&gt;

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

&lt;p&gt;Local-only GUIs are excellent for development, but pointing a bare admin UI at a &lt;strong&gt;production&lt;/strong&gt; table is risky: there are no guardrails around a full-table &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;&lt;code&gt;Scan&lt;/code&gt;&lt;/a&gt;, and an accidental scan on a large table burns read capacity and money. Use a client that shows query cost before you run it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  TablePlus and DataGrip
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Generalist SQL clients with partial DynamoDB support&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These are excellent relational clients whose DynamoDB support is bolted on rather than native.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TablePlus&lt;/strong&gt; added an undocumented DynamoDB connector in 2025: you can view tables and filter items with its visual filter builder, but there's no PartiQL/SQL query editor and DynamoDB has no page in TablePlus's official docs (as of our 2026-06-10 check). It is a perpetual licence — $99 for one device, $129 for two, each with a year of updates — and ships for macOS, Windows and Linux (tableplus.com, checked 2026-08-06).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DataGrip&lt;/strong&gt; added DynamoDB in version 2023.3: data is viewable in the data viewer, "tables with keys and indexes are now introspected", and PartiQL is supported in the code editor. Useful if you already live in a JetBrains IDE; still a viewer-plus-PartiQL surface, not a DynamoDB-native tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; fine as an occasional viewer if you already pay for them; a purpose-built client fits the data model better when DynamoDB is a primary database. See the &lt;a href="https://dynotable.com/compare/datagrip-dynamodb-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;DataGrip comparison&lt;/a&gt; and the &lt;a href="https://dynotable.com/compare/tableplus-dynamodb-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;TablePlus comparison&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  DynoTable
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free read-only plan · from $9/mo billed annually · 30-day trial · macOS (Apple Silicon) / Windows / Linux&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Full disclosure: this is our tool. DynoTable is a desktop DynamoDB GUI client whose differentiator is a &lt;strong&gt;SQL Workbench&lt;/strong&gt; that compiles real SQL — &lt;code&gt;INNER&lt;/code&gt;/&lt;code&gt;LEFT JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, and aggregates like &lt;code&gt;COUNT&lt;/code&gt;, &lt;code&gt;SUM&lt;/code&gt;, and &lt;code&gt;AVG&lt;/code&gt; — down to DynamoDB's actual &lt;code&gt;Query&lt;/code&gt;/&lt;code&gt;Scan&lt;/code&gt; operations.&lt;/p&gt;

&lt;p&gt;That matters because DynamoDB's own SQL surface can't. Per the &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.select.html" rel="noopener noreferrer"&gt;PartiQL &lt;code&gt;SELECT&lt;/code&gt; reference&lt;/a&gt; (checked 2026-08-06), the grammar is &lt;code&gt;SELECT … FROM … WHERE … ORDER BY&lt;/code&gt; only: no &lt;code&gt;JOIN&lt;/code&gt;, no &lt;code&gt;GROUP BY&lt;/code&gt;, no aggregate functions. Every "supports SQL" claim in the table above is PartiQL underneath and hits this wall; the &lt;a href="https://dynotable.com/learn/dynamodb-partiql-vs-sql?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;PartiQL vs SQL guide&lt;/a&gt; walks through exactly where.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The Workbench is honest about its rules, because they ARE DynamoDB's rules: one &lt;code&gt;SELECT&lt;/code&gt; at a time, &lt;code&gt;INNER&lt;/code&gt;/&lt;code&gt;LEFT JOIN&lt;/code&gt; only, and a join target that must be a partition key or GSI partition key. That's SQL within DynamoDB's access-pattern rules, not a magic layer over a full scan.&lt;/p&gt;

&lt;p&gt;The daily-driver basics are covered the same way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visual query builder:&lt;/strong&gt; pill-based filters with an index picker and automatic Query-vs-Scan derivation, so you can see when a filter degrades to a scan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query cost up front:&lt;/strong&gt; in the SQL Workbench and the PartiQL editor, a status bar estimates items and read cost before you run, where the plan is known. That's the guardrail the warning about production scans above is asking for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staged writes:&lt;/strong&gt; edits stage locally into a reviewable diff before anything commits to DynamoDB. (One caveat, stated because it's true: PartiQL DML executes directly with a warning, it does not stage.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials done right:&lt;/strong&gt; the five AWS credential kinds auto-detected from &lt;code&gt;~/.aws&lt;/code&gt;, SSO device flow in-process, MFA TOTP in-app, and DynamoDB Local profiles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI on your own Bedrock:&lt;/strong&gt; the agent runs in your AWS account and cannot write to DynamoDB directly; its writes go through the same staged diff you review. You pay AWS for the tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your table data never touches a DynoTable server: reads and writes go from the app to your AWS account, and your AWS credentials never leave your machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What DynoTable does not do:&lt;/strong&gt; it creates and deletes tables and global secondary indexes and configures TTL, capacity and deletion protection, but there are no backups or point-in-time recovery, no streams configuration, and no CloudWatch monitoring — those stay in the AWS console. There's no bulk update. A profile is one region, the macOS build is Apple Silicon only, and a license covers 2 machines. If those are your daily needs, pick from the tools above.&lt;/p&gt;

&lt;p&gt;Pricing: a free read-only plan (account required), and paid from $12/month, or $9/month billed annually; the 30-day trial needs no credit card. See &lt;a href="https://dynotable.com/compare/dynamodb-gui?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;DynoTable as a DynamoDB GUI&lt;/a&gt; for the full category comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; the right pick when your questions are analytics-shaped and PartiQL leaves you stuck; the wrong pick if you need table administration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feryyzqyt2of1kmexte5z.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feryyzqyt2of1kmexte5z.webp" alt="The DynoTable SQL Workbench running a JOIN with GROUP BY over two DynamoDB tables." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnybhrvjgfkpon7sqi9ts.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnybhrvjgfkpon7sqi9ts.webp" alt="DynoTable's visual filter builder with the index picker and the derived Query badge." width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the best DynamoDB GUI client?
&lt;/h2&gt;

&lt;p&gt;It depends on the job. NoSQL Workbench is the best free modeling tool, Dynobase is the most feature-complete commercial client, Dynomate is the pick for Git-shareable query collections, DocKit is the best free option if you also work across Elasticsearch or MongoDB, dynamodb-admin is the standard for local development, and DynoTable adds the SQL Workbench that runs JOINs, &lt;code&gt;GROUP BY&lt;/code&gt;, and aggregates that PartiQL can't. Start from the work you do most and the choice usually makes itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there a free DynamoDB GUI?
&lt;/h2&gt;

&lt;p&gt;Yes, several. AWS NoSQL Workbench is free and cross-platform (and the safest "official" choice); DocKit is free, open-source, and a genuine live-data client; dynamodb-admin and DynamoIt are free and open-source for local work; and DbVisualizer and Dynomate both ship free tiers with limited feature sets (verified 2026-08-06).&lt;/p&gt;

&lt;p&gt;None of the free options run cross-table &lt;code&gt;JOIN&lt;/code&gt;/&lt;code&gt;GROUP BY&lt;/code&gt;/aggregates; they query DynamoDB through single-table PartiQL. For relational-shaped questions over DynamoDB data, that's where a paid SQL Workbench earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's a good DynamoDB GUI for Mac, Windows, or Docker?
&lt;/h2&gt;

&lt;p&gt;All the desktop clients here ship for macOS, Windows, and Linux; note DynoTable's macOS build is Apple Silicon only, while DbVisualizer and NoSQL Workbench also cover Intel Macs. For &lt;strong&gt;Docker&lt;/strong&gt;-based local setups, dynamodb-admin is the usual pick: point &lt;code&gt;--dynamo-endpoint&lt;/code&gt; at the &lt;code&gt;amazon/dynamodb-local&lt;/code&gt; container's exposed port. DynoTable and Dynobase also connect to local endpoints, including LocalStack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can a DynamoDB GUI run real SQL, with JOINs and aggregates?
&lt;/h2&gt;

&lt;p&gt;Not through PartiQL. DynamoDB's PartiQL &lt;code&gt;SELECT&lt;/code&gt; grammar has no &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, or aggregate functions (per the &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.select.html" rel="noopener noreferrer"&gt;AWS reference&lt;/a&gt;, checked 2026-08-06), so any client that "supports SQL" via PartiQL — Dynobase, DataGrip, DbVisualizer, DocKit — hits the same wall. A tool has to plan and compile those queries into DynamoDB's &lt;code&gt;Query&lt;/code&gt;/&lt;code&gt;Scan&lt;/code&gt; operations itself; DynoTable's SQL Workbench is built to do exactly that. If you only need the raw filter or key conditions for a single API call, the &lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;DynamoDB Expression Builder&lt;/a&gt; generates the correct &lt;code&gt;FilterExpression&lt;/code&gt; / &lt;code&gt;KeyConditionExpression&lt;/code&gt; without any SQL surface at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do I still need a GUI if I have the AWS Console?
&lt;/h2&gt;

&lt;p&gt;Many people switch precisely because of the Console's limits: weak filtering, clumsy pagination, and no real export. A purpose-built GUI adds fast filtering, inline editing, query building with cost visibility, and one-click export. The &lt;a href="https://dynotable.com/compare/dynamodb-console-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;DynamoDB Console alternative&lt;/a&gt; page walks the gap feature by feature.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Concepts the right tool should make easy: &lt;a href="https://dynotable.com/learn/dynamodb-query-vs-scan?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;query vs scan&lt;/a&gt;,
&lt;a href="https://dynotable.com/learn/dynamodb-single-table-design?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;single-table design&lt;/a&gt;, and the
&lt;a href="https://dynotable.com/learn/dynamodb-partiql-vs-sql?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;PartiQL vs SQL&lt;/a&gt; gap that the SQL Workbench fills (with
worked &lt;a href="https://dynotable.com/learn/dynamodb-partiql-examples?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;PartiQL examples&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Free tools that run in your browser, no install: the
&lt;a href="https://dynotable.com/tools/dynamodb-json-converter?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;DynamoDB JSON converter&lt;/a&gt; and the
&lt;a href="https://dynotable.com/tools/dynamodb-expression-builder?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;Expression Builder&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Head-to-head pages: &lt;a href="https://dynotable.com/compare/dynamodb-gui?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;DynamoDB GUI client&lt;/a&gt; overview,
&lt;a href="https://dynotable.com/compare/dynobase-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;Dynobase&lt;/a&gt;,
&lt;a href="https://dynotable.com/compare/dynomate-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;Dynomate&lt;/a&gt;,
&lt;a href="https://dynotable.com/compare/nosql-workbench-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;NoSQL Workbench&lt;/a&gt;,
&lt;a href="https://dynotable.com/compare/datagrip-dynamodb-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;DataGrip&lt;/a&gt;, and
&lt;a href="https://dynotable.com/compare/tableplus-dynamodb-alternative?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;TablePlus&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dynotable.com/download?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=drip&amp;amp;utm_content=best-dynamodb-gui-clients" rel="noopener noreferrer"&gt;Download DynoTable&lt;/a&gt; and run a JOIN against your own tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Pricing and version claims verified 2026-08-06 against each vendor's own pages; TablePlus's DynamoDB-connector capabilities are the older 2026-06-10 check, flagged where they appear. Re-check before relying on any of them. Product names are trademarks of their respective owners; referenced here for identification only.&lt;/em&gt;&lt;/p&gt;

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