<?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: Martin Hicks</title>
    <description>The latest articles on DEV Community by Martin Hicks (@martinhicks).</description>
    <link>https://dev.to/martinhicks</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%2F94005%2F6aef5084-5a35-4e9d-b7fa-a1f1e53a2f87.jpeg</url>
      <title>DEV Community: Martin Hicks</title>
      <link>https://dev.to/martinhicks</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/martinhicks"/>
    <language>en</language>
    <item>
      <title>Dynoxide 1.3.0: security fixes and fussier pagination</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Sat, 10 Oct 2026 23:32:04 +0000</pubDate>
      <link>https://dev.to/martinhicks/dynoxide-130-security-fixes-and-fussier-pagination-1n18</link>
      <guid>https://dev.to/martinhicks/dynoxide-130-security-fixes-and-fussier-pagination-1n18</guid>
      <description>&lt;p&gt;&lt;a href="https://dynoxide.dev" rel="noopener noreferrer"&gt;Dynoxide&lt;/a&gt; 1.3.0 ended up a bit bigger than I'd planned. Working through the compatibility fixes turned up some DynamoDB behaviour I hadn't expected, and I also fixed two security issues that needed advisories.&lt;/p&gt;

&lt;p&gt;There's a lot in the release notes, so I've picked out a few highlights below, including a contribution that makes test runs quieter and a few things to check before upgrading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two security fixes
&lt;/h2&gt;

&lt;p&gt;One of these was in the code that explains why an expression is invalid. A request containing certain non-ASCII characters could make Dynoxide panic while building the syntax error's &lt;code&gt;near:&lt;/code&gt; text. It cut the string in the middle of a UTF-8 character.&lt;/p&gt;

&lt;p&gt;Release binaries abort on panic, so an emoji in a malformed expression could stop the server whilst it was trying to reject the request.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expression parsing (rated High, 7.5):&lt;/strong&gt; affected product versions are 0.9.10 through 1.2.1. An invalid character now gets a syntax error instead. Anyone who can reach the HTTP server could trigger the old behaviour without credentials, because it doesn't check request signatures. The default loopback binding limits that exposure, but a server on a shared network needs the upgrade. An in-memory database goes with the process; a file-backed database retains its last completed write. If you embed &lt;code&gt;dynoxide-rs&lt;/code&gt;, the same call panics rather than aborting the process, unless you build with &lt;code&gt;panic = "abort"&lt;/code&gt;. Details in &lt;a href="https://github.com/nubo-db/dynoxide/security/advisories/GHSA-gwhp-7jch-7xj6" rel="noopener noreferrer"&gt;GHSA-gwhp-7jch-7xj6&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokens in help output (rated Low, 2.8):&lt;/strong&gt; from 0.10.0 through 1.2.1, subcommand help printed the value of &lt;code&gt;DYNOXIDE_MCP_AUTH_TOKEN&lt;/code&gt; if it was set. That's an unfortunate thing to put in a CI log or paste into a bug report. Help now names the variable without showing its value. If you've shared output containing the token, rotate it. Details in &lt;a href="https://github.com/nubo-db/dynoxide/security/advisories/GHSA-3rv6-mrj3-vf9j" rel="noopener noreferrer"&gt;GHSA-3rv6-mrj3-vf9j&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are fixed in product 1.3.0 and Rust crate 3.0.0. The token fix also has a regression test checking which environment-backed flags are allowed to show their values.&lt;/p&gt;

&lt;p&gt;There's a separate &lt;code&gt;aws-smithy-json&lt;/code&gt; dependency update in the release notes. That dependency only reaches Dynoxide through the AWS SDK used by tests and benchmarks, and isn't shipped in the released artefacts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pagination rules are fussier than I'd expected
&lt;/h2&gt;

&lt;p&gt;DynamoDB will give you a PartiQL pagination token, then refuse it if you add a space to the statement on the next request. Send the same list parameter again and it can refuse that too.&lt;/p&gt;

&lt;p&gt;Dynoxide used to accept a pagination token when the statement parsed the same way. DynamoDB binds it to the text as sent, with some rather particular rules for parameters. 1.3.0 follows those rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Statement text must stay identical.&lt;/strong&gt; Whitespace, keyword case and even the order of keys in a map literal matter. You can change &lt;code&gt;Limit&lt;/code&gt; between pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Number parameters must match in value and scale.&lt;/strong&gt; &lt;code&gt;1E2&lt;/code&gt; and &lt;code&gt;1e+2&lt;/code&gt; resume, as do &lt;code&gt;1.5E1&lt;/code&gt; and &lt;code&gt;15&lt;/code&gt;, but &lt;code&gt;100&lt;/code&gt; and &lt;code&gt;1E2&lt;/code&gt; don't, and neither do &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;0.0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set order doesn't matter.&lt;/strong&gt; Reordering the members still lets you resume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List and map parameters don't resume at all.&lt;/strong&gt; DynamoDB hands out a token on the first page, then rejects it even if you send the same parameter again. This was captured against both eu-west-2 and us-east-1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your application rebuilds or reformats its statement between pages, a local test should catch that. Previously Dynoxide let it through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requests that used to do the wrong thing
&lt;/h2&gt;

&lt;p&gt;The fixes I care most about are the ones where a request succeeded and quietly did something else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PartiQL comments or a table alias could lose the filter.&lt;/strong&gt; A &lt;code&gt;--&lt;/code&gt; comment before &lt;code&gt;WHERE&lt;/code&gt; could turn a &lt;code&gt;SELECT&lt;/code&gt; into a read of every row, and so could a table alias such as &lt;code&gt;FROM "T" t&lt;/code&gt;. Comments are now read as whitespace, and an alias is refused, as DynamoDB refuses it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unfilled &lt;code&gt;?&lt;/code&gt; could match items.&lt;/strong&gt; &lt;code&gt;flag &amp;lt;&amp;gt; ?&lt;/code&gt; with no parameters held for every item without &lt;code&gt;flag&lt;/code&gt;, so a &lt;code&gt;DELETE&lt;/code&gt; or &lt;code&gt;UPDATE&lt;/code&gt; short of its parameters could delete or change them. A statement with more placeholders than parameters is now refused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ORDER BY&lt;/code&gt; was accepted and ignored.&lt;/strong&gt; Descending reads now actually descend, with DynamoDB's restrictions on which key combinations can be ordered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Malformed legacy conditions could disappear.&lt;/strong&gt; An invalid &lt;code&gt;Expected&lt;/code&gt; condition could leave a write unguarded, and an invalid &lt;code&gt;QueryFilter&lt;/code&gt; or &lt;code&gt;ScanFilter&lt;/code&gt; could leave a read unfiltered. These requests are now refused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A list write past the end padded the gap with NULLs.&lt;/strong&gt; DynamoDB appends the value. &lt;code&gt;SET tags[20] = :v&lt;/code&gt; on a two-element list now adds a third element.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A storage error could break transaction atomicity.&lt;/strong&gt; SQLite can roll back a transaction itself on a full disk or an I/O error. Dynoxide used to carry on, allowing later actions to commit individually. Both transaction APIs now stop on the storage failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's plenty of error-message work too, but these change what your application reads or writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  More useful figures for tables and vectors
&lt;/h2&gt;

&lt;p&gt;This release fixes how Dynoxide calculates vector write capacity, fills in missing table and index statistics, and makes capacity figures available through MCP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vector writes use four bytes per dimension.&lt;/strong&gt; Previously Dynoxide sized the embedding as a list of numbers, which could add two or three write units for a 1,024-dimension vector. Reads and the 400 KB item limit still measure its numbers, as DynamoDB does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table and index sizes are populated.&lt;/strong&gt; &lt;code&gt;DescribeTable&lt;/code&gt; now reports table sizes and secondary-index counts and sizes that previously came back as zero. Dynoxide reports these live; AWS refreshes them roughly every six hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP tools can return consumed capacity.&lt;/strong&gt; Ask for &lt;code&gt;return_consumed_capacity: "INDEXES"&lt;/code&gt; to get the API's capacity breakdown through the tool response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Global secondary indexes with multiple partition or sort key attributes also work properly now: sorting uses each sort key attribute in turn, queries require every partition key attribute, and items missing any key attribute stay out of the index. PartiQL still can't query these indexes, matching DynamoDB's restriction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quieter test runs, thanks to a contribution
&lt;/h2&gt;

&lt;p&gt;Thanks to &lt;a href="https://github.com/zhua633" rel="noopener noreferrer"&gt;Anvilly Huang (@zhua633)&lt;/a&gt; for contributing &lt;a href="https://github.com/nubo-db/dynoxide/pull/216" rel="noopener noreferrer"&gt;&lt;code&gt;--log quiet&lt;/code&gt; in #216&lt;/a&gt;. If your tests start and stop Dynoxide, it keeps the informational startup and shutdown messages out of their output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dynoxide serve &lt;span class="nt"&gt;--log&lt;/span&gt; quiet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Warnings and errors still print. I've extended the option to MCP and import-and-serve too; the first-run MCP token message and the import report still appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before upgrading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep a copy if you may roll back.&lt;/strong&gt; Opening a persistent database upgrades it to schema version 11, and there's no way back. An older version only misreads the file if it has a multi-attribute GSI, whose entries are rebuilt, or a vector index, whose item sizes are recomputed. From 1.3.0, a file written by a newer Dynoxide is refused rather than opened. This applies to browser databases too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check Rust dependency versions.&lt;/strong&gt; The product is 1.3.0, but &lt;code&gt;dynoxide-rs&lt;/code&gt; is &lt;strong&gt;3.0.0&lt;/strong&gt; because the Rust API has breaking changes. The parser modules are private and some PartiQL request and error structs have new fields. The crate still supports Rust 1.88; building its tests needs 1.94.1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check updates near the item-size limit.&lt;/strong&gt; &lt;code&gt;UpdateItem&lt;/code&gt; now measures the request's update charge alongside the finished item's size. Some previously refused updates succeed, and some previously accepted ones fail, matching the behaviour captured from DynamoDB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://github.com/nubo-db/dynoxide/releases/tag/v1.3.0" rel="noopener noreferrer"&gt;1.3.0 release notes&lt;/a&gt; have the full list. For an npm project, &lt;code&gt;npm install --save-dev dynoxide@1.3.0&lt;/code&gt; pins this release.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>rust</category>
      <category>opensource</category>
      <category>security</category>
    </item>
    <item>
      <title>Parity Suite 3.6 and a month of emulator fixes</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Fri, 09 Oct 2026 15:19:10 +0000</pubDate>
      <link>https://dev.to/martinhicks/parity-suite-36-and-a-month-of-emulator-fixes-fg1</link>
      <guid>https://dev.to/martinhicks/parity-suite-36-and-a-month-of-emulator-fixes-fg1</guid>
      <description>&lt;p&gt;When I wrote up &lt;a href="https://dev.to/articles/parity-suite-300-release-notes"&gt;3.0.0&lt;/a&gt; in August, Floci was a C on the &lt;a href="https://paritysuite.org" rel="noopener noreferrer"&gt;board&lt;/a&gt; with 209 failing tests. By 3rd September the suite had grown to 1,251 tests and it was a D with 337. This morning's run has it failing one.&lt;/p&gt;

&lt;p&gt;That's the real news in this release, and none of it is mine. The suite barely grew. The emulators got better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The board
&lt;/h2&gt;

&lt;p&gt;The suite reached 1,251 tests on 3rd September, so that's the fairest place to measure from. It's at 1,266 now.&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;3rd September&lt;/th&gt;
&lt;th&gt;9th October&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/floci-io/floci" rel="noopener noreferrer"&gt;Floci&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;D, 337 failing&lt;/td&gt;
&lt;td&gt;A, 1 failing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.localstack.cloud" rel="noopener noreferrer"&gt;LocalStack&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;B, 101 failing&lt;/td&gt;
&lt;td&gt;A, 31 failing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/ministackorg/ministack" rel="noopener noreferrer"&gt;MiniStack&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;C, 220 failing&lt;/td&gt;
&lt;td&gt;B, 84 failing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three now pass every Tier 1 test, the core of DynamoDB that most applications rely on. Five weeks ago Floci failed 50 of those, MiniStack 28 and LocalStack 14.&lt;/p&gt;

&lt;p&gt;Floci didn't get there by doing less, either. Its coverage, the share of the suite it attempts rather than skips, went up from 95.1% to 99.4% over the same stretch.&lt;/p&gt;

&lt;p&gt;Its one remaining failure is an edge case. A transaction carries a value nested deeper than DynamoDB's 32-level limit, but only in a condition, so it never gets written. Most regions don't check its depth and go on to evaluate the condition. eu-west-2 and the two other regions that now validate transactions up front (more on that below) refuse the whole request. Floci does neither: it cancels the transaction.&lt;/p&gt;

&lt;p&gt;Most of LocalStack's 31 are PartiQL (21 of them), with the rest in exact error wording and limits. MiniStack's 84 are nearly all Tier 3, mostly exact error messages and limits.&lt;/p&gt;

&lt;p&gt;It's good to see the board being used for what I built it for. Floci's tests and pull requests cite it by name, and MiniStack's maintainers point contributors to it when the question of how close to real DynamoDB they are comes up. Thanks to both teams for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS moved in the middle of a run
&lt;/h2&gt;

&lt;p&gt;3.6.0 was tagged on 8th October and never published. While it was being measured, eu-west-2 changed how it validates transactions, so five tests failed against real DynamoDB and no board came out.&lt;/p&gt;

&lt;p&gt;It's part of the same rollout I &lt;a href="https://dev.to/articles/dynamodb-changed-its-validation-errors"&gt;noticed back in June&lt;/a&gt;, and it still hasn't finished. eu-west-2, eu-north-1 and ap-northeast-2 now check a transaction's request before running it. The other 30 regions don't yet, and a few of the changes are more than wording: the same request can succeed in one region and fail in another.&lt;/p&gt;

&lt;p&gt;For example, take an item that's already there, and a single &lt;code&gt;SET&lt;/code&gt; that brings it up to exactly DynamoDB's 400KB limit (409,600 bytes), sent as an Update inside &lt;code&gt;TransactWriteItems&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// bigValue brings the item to 409,600 bytes&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TransactWriteItemsCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;TransactItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Y&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;UpdateExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SET p = :p&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;ExpressionAttributeValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:p&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bigValue&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In us-east-1 that succeeds and the item is stored at 409,600 bytes. In eu-west-2, since 8th October, it fails with &lt;code&gt;ValidationException: Item size to update has exceeded the maximum allowed size&lt;/code&gt;. If your tests run against one region and production sits in another, the two can disagree on this request. It's another reason &lt;a href="https://dev.to/articles/you-cant-pin-dynamodb-to-one-region"&gt;you can't pin DynamoDB to one region&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;3.6.1 is the fix. Those five tests accept either answer until the regions settle, and no engine loses a test over it. Pinning to the old answer would fail real DynamoDB in eu-west-2, which is the region the suite measures against, and pinning to the new one would fail every engine that matches the 30 regions still giving the old answer. Both answers are what DynamoDB actually does today, so both count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller changes
&lt;/h2&gt;

&lt;p&gt;The board now sorts by grade, best first. It used to sort on divergence alone, which could put a target with a worse grade above a better one. No grade or figure changed.&lt;/p&gt;

&lt;p&gt;The suite grew by fifteen tests across 3.5.0 and 3.6, fourteen of them from &lt;a href="https://github.com/exoego" rel="noopener noreferrer"&gt;exoego&lt;/a&gt;, covering size and nesting limits on batch and transaction writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coming next
&lt;/h2&gt;

&lt;p&gt;The suite's going to grow again soon. I've found some gaps in what it covers, and there are around 160 new tests waiting to go in, all checked against real DynamoDB first. I'll probably merge them early next week, and I'd expect most engines' figures to move when they do.&lt;/p&gt;

&lt;p&gt;The full notes are in the &lt;a href="https://github.com/paritysuite/dynamodb-conformance/releases/tag/v3.6.1" rel="noopener noreferrer"&gt;3.6.1 release&lt;/a&gt;, the suite is at &lt;a href="https://github.com/paritysuite/dynamodb-conformance" rel="noopener noreferrer"&gt;paritysuite/dynamodb-conformance&lt;/a&gt;, and the board is at &lt;a href="https://paritysuite.org" rel="noopener noreferrer"&gt;paritysuite.org&lt;/a&gt;. If something looks wrong, an issue is welcome.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>DynamoDB vector search without embeddings</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Sun, 20 Sep 2026 19:08:25 +0000</pubDate>
      <link>https://dev.to/martinhicks/dynamodb-vector-search-without-embeddings-5m2</link>
      <guid>https://dev.to/martinhicks/dynamodb-vector-search-without-embeddings-5m2</guid>
      <description>&lt;p&gt;&lt;a href="https://serverlessadvocate.substack.com/p/85-agentic-persistent-co-workers" rel="noopener noreferrer"&gt;Serverless Advocate #85&lt;/a&gt; asks &lt;a href="https://builder.aws.com/community/@mlhpdx" rel="noopener noreferrer"&gt;Lee Harding&lt;/a&gt; which AWS service he's most excited about, and he picks DynamoDB vector indexes. Not for embeddings, though. His point is that most people hear "vector" and think semantic search, when a vector index is "a general-purpose tool for any domain where you need to find 'nearest neighbours' in some n-dimensional space". Geometry, topology, geography, sensor fusion.&lt;/p&gt;

&lt;p&gt;So I had a go at the geography case.&lt;/p&gt;

&lt;p&gt;The setup is a store locator, which is about the most ordinary geography question going. Seven real places stand in for shops, five in and around Leeds and two over in York, each carrying its actual latitude and longitude. You stand somewhere and ask which ones are nearest.&lt;/p&gt;

&lt;p&gt;A shop is about as plain as a DynamoDB item gets. Partition key, a name, two numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"PK"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLACE#p-101"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Briggate"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"N"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"53.79756"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"N"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-1.54169"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"openUntil"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"18:00"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No sort key, and the coordinates are ordinary numbers sat on the item. Nothing about that is searchable by distance yet, so the question is what you have to add to make it so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Degrees aren't a space
&lt;/h2&gt;

&lt;p&gt;You can't hand a vector index a latitude and longitude as two numbers, because degrees aren't a space. Cosine measures the angle your pair makes with the origin, and the origin here is just (0, 0). Nothing is measured from there, so two places on opposite sides of the world can score as basically identical. Euclidean is less daft and still wrong, because a degree of longitude is 111km at the equator and 66km up here in Leeds, so a circle in degree space is an ellipse on the ground. Neither is fixable by picking a different distance function. It's the space that's broken, not the metric.&lt;/p&gt;

&lt;p&gt;What works is projecting onto the unit sphere, which is three lines of trig:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That triple goes on the item as &lt;code&gt;position&lt;/code&gt;, an ordinary list of numbers sitting beside the name and the opening hours, because there is no vector type in DynamoDB. A vector index over that attribute is what makes it searchable.&lt;/p&gt;

&lt;p&gt;Now every shop is a point on a ball of radius one, and the straight-line gap between two of those points is &lt;code&gt;2 * sin(theta / 2)&lt;/code&gt;, where theta is the angle between them at the centre of the ball. That climbs steadily from zero to pi and never doubles back, so ordering by it gives you the true great-circle ordering. Not an approximation of it. The score converts back to kilometres with &lt;code&gt;2 * 6371 * asin(score / 2)&lt;/code&gt;, which across those seven shops agrees with haversine to about half a metre.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next to a geohash
&lt;/h2&gt;

&lt;p&gt;Worth running that next to the way proximity normally gets done in DynamoDB, which is a geohash. It interleaves the two coordinates into a single string, so nearby places end up sharing a leading prefix and "what's near me" becomes a prefix read. That's a lovely trick, and DynamoDB is very good at prefix reads.&lt;/p&gt;

&lt;p&gt;Briggate's full hash is &lt;code&gt;gcwfhct3&lt;/code&gt;. To make that queryable you take the first four characters as a cell, key a GSI on it, and keep the whole hash as the sort key so neighbours inside a cell order by position rather than by id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"PK"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLACE#p-101"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Briggate"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"geohash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gcwfhct3"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"GSI1PK"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GEO#gcwf"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"GSI1SK"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gcwfhct3#p-101"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "what's near me" becomes one Query on &lt;code&gt;GSI1PK = GEO#gcwf&lt;/code&gt;, which is cheap and exact. It's also where the problem lives: the cell is the partition key. A shop in a neighbouring cell isn't ranked lower, it's in a different partition, and the query never goes near it.&lt;/p&gt;

&lt;p&gt;I put the query point on the concourse at Leeds station, which sits in geohash cell &lt;code&gt;gcwf&lt;/code&gt;, and asked both. Here are the five nearest shops of the seven, the York pair being a good 35km out and no trouble for either approach:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shop&lt;/th&gt;
&lt;th&gt;Distance&lt;/th&gt;
&lt;th&gt;Cell&lt;/th&gt;
&lt;th&gt;In the &lt;code&gt;gcwf&lt;/code&gt; prefix read?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Briggate&lt;/td&gt;
&lt;td&gt;0.45km&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gcwf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Headrow&lt;/td&gt;
&lt;td&gt;0.48km&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gcwf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kirkgate Market&lt;/td&gt;
&lt;td&gt;0.53km&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gcwf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Holbeck&lt;/td&gt;
&lt;td&gt;0.92km&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gcwc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hyde Park&lt;/td&gt;
&lt;td&gt;2.24km&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gcwf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Holbeck is a mile or so south of Leeds centre, and the cell boundary runs between it and the station. So the geohash hands you four shops, misses the one at 0.92km, and includes Hyde Park at two and a half times the distance because it happens to share four characters with where you're stood. That cell is roughly 23km by 19km, so this isn't a pathological case I've constructed: any query near an edge has it, which is most of them. Everyone who's built one of these knows about the boundary problem in the abstract. It's a bit sharper when the thing hands you a shop over twice as far away and looks perfectly happy about it.&lt;/p&gt;

&lt;p&gt;Both access patterns are loaded in the console below, if you'd rather press it than take my word for it:&lt;/p&gt;

&lt;p&gt;That's the engine running in your browser on the seven shops above. Run the vector search, then switch to the cell query, and Holbeck is the row that stops coming back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three lines of trig is still a model
&lt;/h2&gt;

&lt;p&gt;So it's tempting to call this the version with no model in it. No weights to ship, and nothing to re-embed when a library version moves.&lt;/p&gt;

&lt;p&gt;That's not quite right. Something decided degrees weren't the space and the unit sphere was, and that decision is now part of the index's contract in exactly the way an embedding model would be. Store vectors built one way, query with vectors built another, and nothing will tell you. Swap two components of the projection, or move to a different reference sphere, and you still hand over three finite numbers. The dimension count still matches, and the dimension count is the only thing a vector index ever checks. Every result comes back looking perfectly reasonable.&lt;/p&gt;

&lt;p&gt;Which is the same failure people hit with a mismatched embedding model, with no machine learning anywhere near it. So write down whatever turns your data into numbers, keep it next to the index, and version it. When it changes, re-derive the lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Have a go
&lt;/h2&gt;

&lt;p&gt;The full write-up for the store locator is on &lt;a href="https://accesspatterns.dev/library/geospatial-nearest-neighbour" rel="noopener noreferrer"&gt;accesspatterns.dev&lt;/a&gt;, and there's a &lt;a href="https://accesspatterns.dev/library/store-locator" rel="noopener noreferrer"&gt;geohash-only version&lt;/a&gt; of the same seven shops if you want to poke at the prefix read on its own. Everything runs on &lt;a href="https://dynoxide.dev" rel="noopener noreferrer"&gt;dynoxide&lt;/a&gt;'s WASM engine, so there's no account and nothing goes near AWS.&lt;/p&gt;

&lt;p&gt;I did two of Harding's other cases the same way if you want them. &lt;a href="https://accesspatterns.dev/library/sensor-fusion" rel="noopener noreferrer"&gt;Sensor fusion&lt;/a&gt; is five sensor channels on wildly different scales, where the numbers need normalising before the distance means anything at all, and it's the clearest illustration of the point above. &lt;a href="https://accesspatterns.dev/library/design-token-similarity" rel="noopener noreferrer"&gt;Design tokens&lt;/a&gt; matches a pasted hex to the nearest approved colour, where converting to CIELAB makes the distance function a consequence rather than a choice.&lt;/p&gt;

&lt;p&gt;And if you've got a geohash doing proximity in production, it's worth firing a few queries near a cell edge and looking at what comes back. It won't tell you it's missing anything.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>vectorsearch</category>
      <category>datamodelling</category>
    </item>
    <item>
      <title>Running DynamoDB vector search locally</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Tue, 08 Sep 2026 07:21:32 +0000</pubDate>
      <link>https://dev.to/martinhicks/running-dynamodb-vector-search-locally-203o</link>
      <guid>https://dev.to/martinhicks/running-dynamodb-vector-search-locally-203o</guid>
      <description>&lt;p&gt;&lt;a href="https://dynoxide.dev" rel="noopener noreferrer"&gt;Dynoxide&lt;/a&gt; has had DynamoDB vector search since 1.0.0 landed on the 25th of August: vector indexes, &lt;code&gt;SearchVectors&lt;/code&gt;, and the write paths keeping them current. It's on the native binary, the browser build and the MCP server, so an agent can create an index and search it without any of that reaching AWS.&lt;/p&gt;

&lt;p&gt;I announced it at the time and then went on holiday instead of writing it up. This is the write-up, plus 1.1.0, which landed on the 3rd.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index says ACTIVE about fifteen minutes early
&lt;/h2&gt;

&lt;p&gt;Before building any of it I spent two days pointing things at real DynamoDB and writing down what came back, because I wanted vector search in the &lt;a href="https://dev.to/articles/dynoxide-conformance-suite"&gt;conformance suite&lt;/a&gt; and &lt;a href="https://dev.to/articles/dynamodb-vector-search-docs-get-wrong"&gt;the docs didn't agree with themselves&lt;/a&gt; about when an index is ready.&lt;/p&gt;

&lt;p&gt;Add a vector index to a table that already has data in it and it has to backfill. Fine, expected. So you poll &lt;code&gt;DescribeTable&lt;/code&gt; and wait for &lt;code&gt;IndexStatus&lt;/code&gt; to reach &lt;code&gt;ACTIVE&lt;/code&gt;, which is what you'd do for a GSI and what AWS's docs told you to do at the time.&lt;/p&gt;

&lt;p&gt;It reaches &lt;code&gt;ACTIVE&lt;/code&gt;, you search it, and it refuses.&lt;/p&gt;

&lt;p&gt;On a table with twenty-five items in it, that carried on for about a quarter of an hour.&lt;/p&gt;

&lt;p&gt;Dynoxide does the same thing, deliberately. The index goes &lt;code&gt;ACTIVE&lt;/code&gt;, searches still get refused, and a while later it starts answering. A readiness check built on polling for &lt;code&gt;ACTIVE&lt;/code&gt; therefore breaks on your laptop rather than in production. Put the search in a retry loop and treat the refusal as "not yet".&lt;/p&gt;

&lt;p&gt;The full lifecycle, for reference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;&lt;code&gt;TableStatus&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;IndexStatus&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Backfilling&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;SearchVectors&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Delete of the index&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Allocating&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UPDATING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CREATING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backfilling&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ACTIVE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CREATING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active, not searchable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ACTIVE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ACTIVE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Searchable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ACTIVE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ACTIVE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;served&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The base table catches people out too. It's back to &lt;code&gt;ACTIVE&lt;/code&gt; while the index is still &lt;code&gt;CREATING&lt;/code&gt;, so a table waiter looks like the right gate about three phases too early.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;Backfilling&lt;/code&gt; doesn't go &lt;code&gt;false&lt;/code&gt; when it finishes, it disappears. Which means "wait until &lt;code&gt;IndexStatus&lt;/code&gt; is &lt;code&gt;ACTIVE&lt;/code&gt; and &lt;code&gt;Backfilling&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;" never comes true, on AWS or here. That was AWS's own documented advice until they &lt;a href="https://dev.to/articles/dynamodb-vector-search-docs-get-wrong"&gt;rewrote it&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Timings are the one thing not to copy across. Dynoxide's phases run in tens of seconds so you can watch them happen; AWS's are minutes and vary with the table. Don't write &lt;code&gt;sleep(30)&lt;/code&gt; and assume you're ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it won't ever match
&lt;/h2&gt;

&lt;p&gt;Dynoxide compares your query against every vector in the index, so you get the genuinely nearest results back. AWS builds an approximate index and skips most of the comparisons, which is how it stays quick on a huge table and why it will occasionally miss something that was really there. So don't pin an exact list of results in a test that also runs against AWS - assert what you actually care about, like "the item I put in comes back". It also means a local table holding millions of vectors isn't what this is for.&lt;/p&gt;

&lt;p&gt;Ties are the other one. Dynoxide breaks them by primary key, AWS promises nothing, and three identical calls came back in three different orders. Sort them yourself if it matters.&lt;/p&gt;

&lt;p&gt;My favourite is &lt;code&gt;VectorSearchRequestBytes&lt;/code&gt;, where AWS can't reproduce its own number. Five identical searches over an index nobody had touched: 14214, 13903, 14214, 14214, 14518. Dynoxide reports something stable instead, which is handy for comparing one local search against another and useless for asserting against AWS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three months watching one error message
&lt;/h2&gt;

&lt;p&gt;1.1.0 is a single behaviour change, and it's the end of something I started writing about in June.&lt;/p&gt;

&lt;p&gt;Back then &lt;a href="https://dev.to/articles/dynamodb-changed-its-validation-errors"&gt;my conformance suite went red and the thing that failed was AWS&lt;/a&gt;. It had quietly reworded a pile of its validation errors, with nothing in any changelog. I checked four regions: two had the new wording, two didn't. I said I reckoned that was a rollout caught mid-flight rather than a permanent regional split, and that I had no way to prove it.&lt;/p&gt;

&lt;p&gt;The suite sweeps 33 regions now. As of the 2nd September, &lt;code&gt;BatchGetItem&lt;/code&gt; with an empty &lt;code&gt;RequestItems&lt;/code&gt; map answers the new wording in 32 of them, and me-central-1 is the last holdout. So it was a rollout, and it took about three months.&lt;/p&gt;

&lt;p&gt;The part I didn't see coming is that it moves per operation as well as per region. &lt;code&gt;BatchWriteItem&lt;/code&gt; sits right beside it, takes the same empty map, hits the same validation, and is still on the old wording nearly everywhere - one region, eu-north-1, had crossed as of the 22nd August. Dynoxide follows eu-west-2, so one of the pair changed in 1.1.0 and its sibling didn't. They answer differently now, with a test pinning each, because that's what the real thing does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring bits
&lt;/h2&gt;

&lt;p&gt;1.0.0 carried a lot besides vector search. Consumed capacity is sized the way DynamoDB sizes it now, a stack of PartiQL fixes landed, and several requests that used to be accepted aren't any more. If you've got tests asserting on exact answers, read &lt;a href="https://github.com/nubo-db/dynoxide/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;the changelog&lt;/a&gt; before you upgrade.&lt;/p&gt;

&lt;p&gt;One worth calling out on its own: &lt;code&gt;StorageBackend&lt;/code&gt; is sealed. Vector indexes alone added seven methods to that trait, and left open, every one of those would have been a major.&lt;/p&gt;

&lt;p&gt;Everything moved to 1.0.0 together and the browser engine dropped its &lt;code&gt;-preview&lt;/code&gt; suffix. That number isn't a claim that conformance is finished - the &lt;a href="https://paritysuite.org" rel="noopener noreferrer"&gt;live standings&lt;/a&gt; are the honest answer to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;code&gt;npm install --save-dev dynoxide&lt;/code&gt; to pin it in a project, or &lt;code&gt;npx dynoxide&lt;/code&gt; to run the latest without installing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homebrew:&lt;/strong&gt; &lt;code&gt;brew install nubo-db/tap/dynoxide&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cargo:&lt;/strong&gt; &lt;code&gt;cargo install dynoxide-rs&lt;/code&gt; (&lt;a href="https://crates.io/crates/dynoxide-rs" rel="noopener noreferrer"&gt;crates.io&lt;/a&gt;, &lt;a href="https://docs.rs/dynoxide-rs" rel="noopener noreferrer"&gt;docs.rs&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker:&lt;/strong&gt; &lt;code&gt;ghcr.io/nubo-db/dynoxide&lt;/code&gt;, mirrored best-effort to Docker Hub (&lt;code&gt;nubodb/dynoxide&lt;/code&gt;) and ECR Public&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/nubo-db/dynoxide/releases/tag/v1.1.0" rel="noopener noreferrer"&gt;source and pre-built binaries&lt;/a&gt;, plus the &lt;code&gt;nubo-db/dynoxide/action&lt;/code&gt; GitHub Action for CI&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rust</category>
      <category>dynamodb</category>
      <category>vectorsearch</category>
      <category>aws</category>
    </item>
    <item>
      <title>When a DynamoDB vector index is actually ready</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Thu, 13 Aug 2026 16:07:07 +0000</pubDate>
      <link>https://dev.to/martinhicks/when-a-dynamodb-vector-index-is-actually-ready-5d09</link>
      <guid>https://dev.to/martinhicks/when-a-dynamodb-vector-index-is-actually-ready-5d09</guid>
      <description>&lt;p&gt;AWS shipped &lt;a href="https://aws.amazon.com/blogs/aws/amazon-dynamodb-now-supports-real-time-vector-search-at-any-scale/" rel="noopener noreferrer"&gt;vector search for DynamoDB&lt;/a&gt; on 5th August, straight to GA. I spent a couple of days pinning it down against real DynamoDB so I could add it to my &lt;a href="https://paritysuite.org" rel="noopener noreferrer"&gt;conformance suite&lt;/a&gt;, which meant reading the docs properly and then checking whether they were true.&lt;/p&gt;

&lt;p&gt;Mostly they are. But there's one thing they get wrong three different ways, and the three stack up: knowing when a vector index is ready to search.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tutorial contradicts everything else
&lt;/h2&gt;

&lt;p&gt;Add a vector index to a table that already has data in it and there's a gap before you can use it, while DynamoDB backfills the index from the items you already have. What happens if you search during that gap depends on which page you read.&lt;/p&gt;

&lt;p&gt;Three pages say you get an error. The &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/VectorSearchDataSync.html" rel="noopener noreferrer"&gt;data synchronisation page&lt;/a&gt; puts it plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;New writes to the base table are replicated to the index during this phase, but &lt;code&gt;SearchVectors&lt;/code&gt; returns an error until backfilling finishes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/VectorSearchWorkingWith.html" rel="noopener noreferrer"&gt;Creating and searching&lt;/a&gt; has a callout headed "You can't search while the index is backfilling", and &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/VectorSearchTroubleshooting.html" rel="noopener noreferrer"&gt;troubleshooting&lt;/a&gt; carries a row for it. The &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/VectorSearchTutorial.html" rel="noopener noreferrer"&gt;tutorial&lt;/a&gt; says something different:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Searching an index that is not yet &lt;code&gt;ACTIVE&lt;/code&gt; fails, and searching during backfill can return incomplete results.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those aren't the same problem at all. An error you catch and retry. Incomplete results you can't spot, because a short answer from an approximate search looks exactly like a short answer from a thin index. If the tutorial were right, every search during a backfill would be quietly wrong with nothing to tell you.&lt;/p&gt;

&lt;p&gt;It isn't right. You get an error, and there are two of them depending on how far along the index is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValidationException: The table does not have the specified index: vix
ValidationException: Cannot search backfilling vector index: vix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So trust the other three. Worth saying out loud, because the tutorial is the page you'd be following if this is your first vector index.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state machine doesn't exist
&lt;/h2&gt;

&lt;p&gt;This is the one that would actually bite.&lt;/p&gt;

&lt;p&gt;Those three pages all describe the index moving through the same three states: CREATING while DynamoDB sets up the infrastructure, then a middle state, then ACTIVE with &lt;code&gt;Backfilling: false&lt;/code&gt; once it's done. The data synchronisation page names the middle one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It then moves to &lt;code&gt;IndexStatus&lt;/code&gt; &lt;code&gt;ACTIVE&lt;/code&gt; with &lt;code&gt;Backfilling&lt;/code&gt; set to &lt;code&gt;true&lt;/code&gt; while DynamoDB populates the index from existing base table data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every piece of waiting advice in the documentation hangs off that state.&lt;/p&gt;

&lt;p&gt;I polled &lt;code&gt;DescribeTable&lt;/code&gt; every five seconds through the creation of an index added to a table that already had items in it. This is what I got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATING   Backfilling: false
CREATING   Backfilling: true
ACTIVE     Backfilling: (gone)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no middle state. &lt;code&gt;Backfilling&lt;/code&gt; is a CREATING-time field, and when the index flips to ACTIVE the field doesn't settle to false, it vanishes from the response.&lt;/p&gt;

&lt;p&gt;That breaks the advice, because the advice tells you to wait for a value that never arrives. Data synchronisation again, in the sentence straight after the one I quoted earlier:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use &lt;code&gt;DescribeTable&lt;/code&gt; and wait until &lt;code&gt;IndexStatus&lt;/code&gt; is &lt;code&gt;ACTIVE&lt;/code&gt; and &lt;code&gt;Backfilling&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; before you search.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Write that literally and your poll never finishes. By the time the status is ACTIVE, &lt;code&gt;Backfilling&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, not &lt;code&gt;false&lt;/code&gt;. One page out of the four allows for that, and it does it in a parenthesis: "&lt;code&gt;Backfilling&lt;/code&gt; set to &lt;code&gt;false&lt;/code&gt; (or absent)".&lt;/p&gt;

&lt;p&gt;Create the index as part of &lt;code&gt;CreateTable&lt;/code&gt; instead of adding it later and the field never turns up at all. That one I do assert, on every poll, so if AWS starts reporting it my tests break:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ix&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;Backfilling&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeUndefined&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me be straight about the evidence, though, because the two halves aren't equally solid. That assertion is real and runs on every scheduled run. The three-line transcript above isn't pinned: it's something I recorded on 11th August, and the test only checks the field showed up at some point, without checking that &lt;code&gt;true&lt;/code&gt; appears solely alongside CREATING. It's &lt;a href="https://github.com/paritysuite/dynamodb-conformance/blob/80b6ef79078386759d16a2bf9c88e972f4e73fd2/captures/2026-08-12-vector-backfill-docs.json" rel="noopener noreferrer"&gt;in the capture file&lt;/a&gt; with the documented version quoted next to it if you want to check the mismatch yourself. A gap in my tests rather than in the finding, and it's on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  ACTIVE arrives too early
&lt;/h2&gt;

&lt;p&gt;Even once the status says ACTIVE, the index can still not be ready.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DescribeTable&lt;/code&gt; goes ACTIVE a beat before the search plane agrees. Search on that first ACTIVE and you get failures that look like flakiness and aren't. It's a small window, which is worse than a big one: a window small enough to survive your local testing still opens wherever things run fastest.&lt;/p&gt;

&lt;p&gt;That bites hardest when you create the index with the table, because there's no &lt;code&gt;Backfilling&lt;/code&gt; field on that path and the tutorial tells you to go on status alone:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use &lt;code&gt;IndexStatus&lt;/code&gt; alone as your signal in that case.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Status alone is the signal that arrives early.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to wait for it properly
&lt;/h2&gt;

&lt;p&gt;Prove the index is ready with a search, not with a description. My suite polls until a search comes back with everything it put in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SearchVectorsCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IndexName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SearchVector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;TopK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SearchResults&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expectedCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Not ready yet. Anything else is a real failure.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ValidationException&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The count check earns its place as much as the try/catch does. A search can start succeeding while the index still has a partial view of your data, so a bare "did it throw" test lets you through early with fewer results than you wrote.&lt;/p&gt;

&lt;p&gt;Budget some time for it too. An index added to a table holding 25 items took about 17 minutes to become searchable. It's running on the same online-index machinery as a GSI, and the size of the table isn't what drives that number. It inherits the GSI concurrency limit as well, error message and all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LimitExceededException: Subscriber limit exceeded: Only 1 online index
can be created or deleted simultaneously per table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Other things worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The index keeps your embeddings at f32 and the table doesn't.&lt;/strong&gt; The docs do say this, so it's not a discovery, but it's worth seeing how big the gap is. &lt;code&gt;16777217&lt;/code&gt; is the first integer a 32-bit float can't hold. Write it into an embedding and the base table gives it back exactly. Ask for the same attribute through a search projection and you get &lt;code&gt;16777216&lt;/code&gt;. The index also prints its f32 copy as the shortest decimal that names the float, so &lt;code&gt;0.1&lt;/code&gt; comes back as &lt;code&gt;0.1&lt;/code&gt; rather than &lt;code&gt;0.100000001490116119384765625&lt;/code&gt;, which makes the whole thing harder to notice than it sounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vector capacity has a shape nothing else in DynamoDB uses.&lt;/strong&gt; A search reports a bare object with no capacity units and no table name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;VectorSearchRequestBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writes report a &lt;code&gt;VectorIndexes&lt;/code&gt; map, but only under &lt;code&gt;ReturnConsumedCapacity: 'INDEXES'&lt;/code&gt;. Ask for &lt;code&gt;TOTAL&lt;/code&gt; and you get nothing vector-related at all, not even rolled into the total, so the two modes aren't detail levels of the same number here.&lt;/p&gt;

&lt;p&gt;That 1024 is interesting on its own. A three-dimension vector is twelve bytes of f32, so it looks like a 1KB floor rather than anything to do with the size of your vector. One run isn't enough to assert a formula, so my tests check it's positive and stop there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No emulator implements any of this yet.&lt;/strong&gt; I score eight DynamoDB emulators against real AWS on every run and all eight skip the whole family. DynamoDB Local is the one to watch: it takes &lt;code&gt;VectorIndexes&lt;/code&gt; on &lt;code&gt;CreateTable&lt;/code&gt; quite happily and then silently drops them, so a &lt;code&gt;DescribeTable&lt;/code&gt; afterwards shows no indexes at all. If you're testing locally against it, the vector index you think you created isn't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need a recent SDK.&lt;/strong&gt; &lt;code&gt;@aws-sdk/client-dynamodb&lt;/code&gt; 3.1103.0 or later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evidence
&lt;/h2&gt;

&lt;p&gt;Both sides of the backfill contradiction are in &lt;a href="https://github.com/paritysuite/dynamodb-conformance/blob/80b6ef79078386759d16a2bf9c88e972f4e73fd2/captures/2026-08-12-vector-backfill-docs.json" rel="noopener noreferrer"&gt;captures/2026-08-12-vector-backfill-docs.json&lt;/a&gt;, dated, with their URLs and anchors, along with the documented state machine, the transcript that doesn't match it, and which of the two paths is pinned by an assertion. Check any of it rather than taking my word for it.&lt;/p&gt;

&lt;p&gt;The tests live in the same repo under &lt;code&gt;tests/tier2/vectorSearch&lt;/code&gt; and run against real DynamoDB on a schedule, so if AWS changes something here I'll find out. If you've hit anything else in it, &lt;a href="https://github.com/paritysuite/dynamodb-conformance/issues" rel="noopener noreferrer"&gt;tell me&lt;/a&gt; and I'll add a test.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>vectorsearch</category>
      <category>testing</category>
    </item>
    <item>
      <title>What a write to an indexed DynamoDB table costs</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Thu, 13 Aug 2026 16:07:04 +0000</pubDate>
      <link>https://dev.to/martinhicks/what-a-write-to-an-indexed-dynamodb-table-costs-58fn</link>
      <guid>https://dev.to/martinhicks/what-a-write-to-an-indexed-dynamodb-table-costs-58fn</guid>
      <description>&lt;p&gt;My &lt;a href="https://paritysuite.org" rel="noopener noreferrer"&gt;conformance suite&lt;/a&gt; has had a per-index capacity assertion since fairly early on. It was on a &lt;code&gt;Query&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which is the wrong half. Reading through an index is the cheap, obvious, well-understood part. The write side is where a secondary index actually costs you money, because every write to the base table fans out to every index the item lands in, and you pay for each one. That went unmeasured for months, until &lt;a href="https://github.com/spaceemotion" rel="noopener noreferrer"&gt;spaceemotion&lt;/a&gt; filed &lt;a href="https://github.com/paritysuite/dynamodb-conformance/issues/124" rel="noopener noreferrer"&gt;an issue&lt;/a&gt; pointing straight at the gap and listing the four cases it needed to cover. All four are below.&lt;/p&gt;

&lt;p&gt;So I fixed it, and measured the lot against real DynamoDB in eu-west-2. Fourteen tests, every integer below observed rather than lifted from the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;One on-demand table, a composite key, and two indexes chosen so each one can be isolated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;KeySchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;KeyType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HASH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;KeyType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RANGE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="nx"&gt;GlobalSecondaryIndexes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="na"&gt;IndexName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gsi-inc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;KeySchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gsiPk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;KeyType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HASH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;Projection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ProjectionType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INCLUDE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;NonKeyAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;proj&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="nx"&gt;LocalSecondaryIndexes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="na"&gt;IndexName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lsi1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;KeySchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;KeyType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HASH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lsiSk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;KeyType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RANGE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;Projection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ProjectionType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ALL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GSI uses an &lt;code&gt;INCLUDE&lt;/code&gt; projection carrying exactly one non-key attribute, &lt;code&gt;proj&lt;/code&gt;. That's what gives the update tests a lever: I can touch an attribute the GSI projects, or one it doesn't, and watch the difference. The LSI projects everything, which sets up the mirror case.&lt;/p&gt;

&lt;p&gt;Every item is under 1KB, so each index write is exactly one unit and the arithmetic shows structure rather than rounding. Every test seeds a fresh key, for a reason that becomes obvious further down.&lt;/p&gt;

&lt;p&gt;All of it read back with &lt;code&gt;ReturnConsumedCapacity: 'INDEXES'&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The basic shape
&lt;/h2&gt;

&lt;p&gt;A write costs one unit for the table, plus one for each index it lands in.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Write&lt;/th&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;GSI&lt;/th&gt;
&lt;th&gt;LSI&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Item with both index keys&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Item with the GSI key only&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Item with the LSI key only&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Item with neither&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things there are worth stating plainly because the docs are vague on both.&lt;/p&gt;

&lt;p&gt;LSI units fold into the top-level total exactly as GSI units do. I'd half expected local indexes to be free on the write path, given they share a partition with the item. They aren't.&lt;/p&gt;

&lt;p&gt;And a write that costs an index nothing reports no arm for that index at all. Not a zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Sparse write, misses both indexes&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;CapacityUnits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;CapacityUnits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// GlobalSecondaryIndexes: absent&lt;/span&gt;
  &lt;span class="c1"&gt;// LocalSecondaryIndexes: absent&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're summing arms to reconcile a bill, absent and zero need the same handling, and a naive &lt;code&gt;Object.keys(cc.GlobalSecondaryIndexes)&lt;/code&gt; throws rather than returning nothing. That's the sort of thing that survives testing right up until a sparse write shows up in production.&lt;/p&gt;

&lt;p&gt;Two more shape notes. Under &lt;code&gt;ReturnConsumedCapacity: 'TOTAL'&lt;/code&gt; you get the correct total and nothing else, with no &lt;code&gt;Table&lt;/code&gt; field and no index arms. And under &lt;code&gt;INDEXES&lt;/code&gt;, the arms you do get carry only an aggregate &lt;code&gt;CapacityUnits&lt;/code&gt;, with no read/write split inside them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The update ladder
&lt;/h2&gt;

&lt;p&gt;This is the interesting part, and the part I'd get wrong if I were guessing.&lt;/p&gt;

&lt;p&gt;Updating an item costs the index nothing, one unit, or two, depending entirely on what you touched. Same item, same table, four different updates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Update&lt;/th&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;GSI&lt;/th&gt;
&lt;th&gt;LSI&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SET other = :v&lt;/code&gt; (not projected by the GSI)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SET proj = :v&lt;/code&gt; (projected by the GSI)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SET gsiPk = :v&lt;/code&gt; (moves the GSI key)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;REMOVE gsiPk&lt;/code&gt; (leaves the GSI)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two-unit row is the one to internalise. Changing an item's GSI partition key isn't an update on that index, it's a delete from the old partition and an insert into the new one, and you pay for both. So a status attribute that doubles as a GSI key costs two index writes on every transition, not one, and a workflow that moves an item through several states pays that each time.&lt;/p&gt;

&lt;p&gt;Removing the key costs one, because there's a delete and no insert.&lt;/p&gt;

&lt;p&gt;The first row shows the projection doing its job: &lt;code&gt;other&lt;/code&gt; isn't in the GSI's &lt;code&gt;INCLUDE&lt;/code&gt; list, so the GSI's stored copy of the item didn't change and the GSI charged nothing. The LSI still charged one, because it projects &lt;code&gt;ALL&lt;/code&gt; and its copy did change.&lt;/p&gt;

&lt;p&gt;That mirror case holds in the other direction too. Updating the LSI's sort key costs the LSI two, a delete and an insert, and costs the GSI nothing, because &lt;code&gt;lsiSk&lt;/code&gt; sits outside the GSI's projection:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Update&lt;/th&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;GSI&lt;/th&gt;
&lt;th&gt;LSI&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SET lsiSk = :v&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the rule underneath all of this is not about keys or attributes as such. An index charges you when the bytes it stores change, and the projection decides which bytes those are. A wide projection is a bigger write bill on every update, not just a bigger index.&lt;/p&gt;

&lt;p&gt;One caveat if you're scaling any of this up. Every item here is under 1KB, so each charge is a single unit and the ladder shows structure rather than rounding. Above 1KB the shapes stop agreeing: an in-place entry update and a key move are no longer the same arithmetic, and the ladder's integers stop generalising. I've measured where they diverge and I'll write that up separately with the capture behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The overwrite that costs nothing
&lt;/h2&gt;

&lt;p&gt;Put an item, then put the identical item again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;sk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;gsiPk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;g4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;lsiSk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;L1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;proj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// total 3: table 1, gsi 1, lsi 1&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// total 1: table 1, no index arms at all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second write charges for the table and nothing else. Index replication is delta-based, so re-writing bytes an index already holds costs nothing on that index.&lt;/p&gt;

&lt;p&gt;This is why every test in the file seeds a fresh key. I lost an hour to a test that passed alone and failed in the suite, because a retry was overwriting an item it had already written and getting a legitimately smaller bill for it.&lt;/p&gt;

&lt;p&gt;Practically, this makes idempotent writes cheaper than they look. A retry loop that re-puts the same item is paying for one table write per attempt, not the full fan-out. It also means you can't infer index cost from write volume, because a workload that rewrites unchanged records has an index bill near zero.&lt;/p&gt;

&lt;p&gt;The same behaviour shows up on &lt;a href="https://dev.to/articles/dynamodb-vector-search-docs-get-wrong"&gt;DynamoDB's new vector indexes&lt;/a&gt;, where an overwrite with an identical embedding reports no vector write capacity whatsoever. Same replication layer, presumably.&lt;/p&gt;

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

&lt;p&gt;Deletes mirror puts. One unit for the table, one for each index the item occupied:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Delete&lt;/th&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;GSI&lt;/th&gt;
&lt;th&gt;LSI&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Item that was in both indexes&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Item that was in neither&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;BatchWriteItem&lt;/code&gt; reports one entry per table with the arms the batch actually touched, aggregated. Two puts in one batch, one indexed and one sparse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;CapacityUnits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;CapacityUnits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;      &lt;span class="c1"&gt;// both items&lt;/span&gt;
  &lt;span class="nx"&gt;GlobalSecondaryIndexes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gsi-inc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;CapacityUnits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// one item&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table arm counts both writes, the index arm counts only the one that landed in it. There's no per-item breakdown, so a batch is the point where you stop being able to attribute cost to a specific write.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;p&gt;The headline number people carry around is "an index roughly doubles your write cost", and that's about right for a table with one index where every item carries the key. It stops being right in both directions fairly quickly.&lt;/p&gt;

&lt;p&gt;A sparse index costs nothing on writes that miss it, which makes sparse indexes cheaper than their read behaviour suggests. A narrow projection costs nothing on updates that don't touch projected attributes. And an attribute that's both a GSI key and a mutable field is the expensive shape, because every change to it is two index writes rather than one.&lt;/p&gt;

&lt;p&gt;If you want the numbers for your own schema rather than mine, &lt;code&gt;ReturnConsumedCapacity: 'INDEXES'&lt;/code&gt; on a scratch table is a twenty-minute exercise and it will tell you more than the docs will. The tests behind this post are in &lt;a href="https://github.com/paritysuite/dynamodb-conformance" rel="noopener noreferrer"&gt;paritysuite/dynamodb-conformance&lt;/a&gt; under &lt;code&gt;tests/tier1/putItem/indexConsumedCapacity.test.ts&lt;/code&gt;, and they re-run against real DynamoDB on a schedule, so if any of these integers move I'll know.&lt;/p&gt;

&lt;p&gt;On their first run those tests put ten failures against Dynoxide, my own engine, scored by the same suite.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>performance</category>
      <category>testing</category>
    </item>
    <item>
      <title>Parity Suite 3.0.0: measuring the wrong thing</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Thu, 13 Aug 2026 15:05:47 +0000</pubDate>
      <link>https://dev.to/martinhicks/parity-suite-300-measuring-the-wrong-thing-7fa</link>
      <guid>https://dev.to/martinhicks/parity-suite-300-measuring-the-wrong-thing-7fa</guid>
      <description>&lt;p&gt;Since the &lt;a href="https://paritysuite.org" rel="noopener noreferrer"&gt;board&lt;/a&gt; started it has published one number per emulator: a pass rate over the tests that emulator attempted. Skipped tests left the sum entirely.&lt;/p&gt;

&lt;p&gt;Which means the number could move without the engine changing a line. In July one emulator gained 8.4 points having shipped nothing at all.&lt;/p&gt;

&lt;p&gt;3.0.0 replaces that number. Two figures instead of one, a letter grade on every target, and the suite is up to 1,054 tests with the first coverage of DynamoDB's new vector search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two figures, never added together
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Coverage&lt;/strong&gt; is how much of DynamoDB the emulator supports. &lt;strong&gt;Divergence&lt;/strong&gt; is how much of it the emulator gets wrong. Both are measured over the whole suite, so neither can hide inside the other.&lt;/p&gt;

&lt;p&gt;They're reported apart because a skip and a fail are different problems. An operation your emulator declines is something you find in minutes and plan around. One it gets quietly wrong is something you find in production.&lt;/p&gt;

&lt;p&gt;The July case was Dynalite, and it works as an example precisely because Dynalite did nothing. It passed 673 tests before and 673 after. Two things happened, both of them mine.&lt;/p&gt;

&lt;p&gt;First I grew the suite by 44 PartiQL tests Dynalite doesn't implement. Every one landed as a skip, and the old number didn't move at all, because skips left the sum entirely. The job got 44 tests bigger and the score couldn't see it.&lt;/p&gt;

&lt;p&gt;Then I improved the probes that spot Dynalite doesn't do transactions, which turned 88 failing tests into skips. That the old number could see. It climbed from 76.0% to 84.3%, and I published an 8.4 point gain for an engine nobody had touched.&lt;/p&gt;

&lt;p&gt;Under the new figures both show up. The transaction reclassification drops divergence and coverage by 8.8 points each, together, measured over the suite as it stood in July. The suite growth drops coverage while divergence barely moves, which is what "the target didn't change, its job got bigger" should look like.&lt;/p&gt;

&lt;p&gt;Nothing was re-run for this. No pass, fail or skip changed, so a figure that looks different from last month is the same measurement described better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letter grades
&lt;/h2&gt;

&lt;p&gt;Two figures are harder to skim than one, so every target wears a letter as well.&lt;/p&gt;

&lt;p&gt;Divergence sets it: A under 5%, B under 15%, C under 25%, D under 35%, F beyond. Coverage can only pull it down. A third of whatever a target leaves unimplemented is added to its divergence before the bands are read, so an engine covering the whole suite is graded on divergence alone.&lt;/p&gt;

&lt;p&gt;A+ is zero divergence at full coverage.&lt;/p&gt;

&lt;p&gt;The criteria are versioned and dated in &lt;a href="https://paritysuite.org/methodology" rel="noopener noreferrer"&gt;the methodology&lt;/a&gt;. Moving a threshold regrades engines whose results never changed, and that should be a visible act rather than a quiet edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The board today
&lt;/h2&gt;

&lt;p&gt;Dynoxide is mine, so a board I author has my own engine at the top of it. The disclosure sits on the card and on the methodology page, and the oracle is derived at build time rather than by me.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Grade&lt;/th&gt;
&lt;th&gt;Divergence&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynoxide · native&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;0.9%&lt;/td&gt;
&lt;td&gt;94.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;↳ WebAssembly / OPFS&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;0.9%&lt;/td&gt;
&lt;td&gt;83.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ExtendDB · PostgreSQL&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;2.2%&lt;/td&gt;
&lt;td&gt;87.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ministack&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;11.9%&lt;/td&gt;
&lt;td&gt;96.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynalite&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;12.8%&lt;/td&gt;
&lt;td&gt;77.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LocalStack&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;14.8%&lt;/td&gt;
&lt;td&gt;95.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB Local&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;15.1%&lt;/td&gt;
&lt;td&gt;94.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Floci&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;21.0%&lt;/td&gt;
&lt;td&gt;95.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those are the 12th August run. The board re-runs against live AWS on a schedule, so &lt;a href="https://paritysuite.org" rel="noopener noreferrer"&gt;the live figures&lt;/a&gt; are the ones to trust if these have moved.&lt;/p&gt;

&lt;p&gt;Dynoxide had zero divergence before this release. It's at 0.9% now, and all ten of its failures are in &lt;code&gt;tests/tier1/putItem/indexConsumedCapacity.test.ts&lt;/code&gt;, a file this release added after &lt;a href="https://github.com/spaceemotion" rel="noopener noreferrer"&gt;spaceemotion&lt;/a&gt; filed &lt;a href="https://github.com/paritysuite/dynamodb-conformance/issues/124" rel="noopener noreferrer"&gt;an issue&lt;/a&gt; pointing out that the suite wasn't measuring index write costs at all. The tests caught ten Tier 1 failures in my own engine on their first run, and the board published them the same day. Tier 1 is the tier the suite describes as what most DynamoDB users rely on, so that's the worst place to find them.&lt;/p&gt;

&lt;p&gt;Real DynamoDB reads &lt;code&gt;baseline&lt;/code&gt; rather than a letter and sits in a panel above the standings now. Grading the yardstick against itself would put it in a band the engines have to earn.&lt;/p&gt;

&lt;p&gt;Dynalite and LocalStack are both being held down by coverage. Each would be a B on divergence alone, and in both cases the coverage shortfall drops them to C. Their rows say so, because the two Cs on the board aren't the same shape of C.&lt;/p&gt;

&lt;p&gt;How to actually use it: if you need specific operations, ignore the letter and go to the target's page, where &lt;strong&gt;What it doesn't attempt&lt;/strong&gt; names the coverage gap directly. That's the answer to "will this emulator do what my app does". If you're picking blind for general CI use, the letter is the shortlist and divergence breaks the tie, because a high-coverage engine that gets things wrong will cost you more debugging than a narrow one that's honest about its limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's new in the suite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Vector search.&lt;/strong&gt; AWS shipped it on 5th August and the suite covers its deterministic surface as of this release: 42 tests, all characterised against real DynamoDB in eu-west-2 first. The interesting part was index readiness, where the documentation is wrong three separate ways: the pages contradict each other on what a premature search does, they describe a &lt;code&gt;Backfilling&lt;/code&gt; state the index never enters, and the status they tell you to wait for arrives before the index can answer. That's written up in &lt;a href="https://dev.to/articles/dynamodb-vector-search-docs-get-wrong"&gt;when a DynamoDB vector index is actually ready&lt;/a&gt;, with both sides of the contradiction captured in the repo. No emulator implements any of it yet, so every coverage figure on the board dropped when these landed, while divergence stayed untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Index write costs.&lt;/strong&gt; The suite's only per-index capacity assertion was on a &lt;code&gt;Query&lt;/code&gt;, so the write side, the half you get billed extra for, went unmeasured. Fourteen new Tier 1 tests fix that. Moving an item to a new GSI key costs two units on that index rather than one, and an overwrite that changes nothing costs nothing at all. The full set of numbers is in &lt;a href="https://dev.to/articles/what-a-write-to-an-indexed-dynamodb-table-costs"&gt;what a write to an indexed DynamoDB table costs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Housekeeping
&lt;/h2&gt;

&lt;p&gt;A target's page should be readable by someone who's never seen the project. &lt;strong&gt;How to run it&lt;/strong&gt; lists every way the target is distributed, Docker image, npx, cargo, a JAR, each linking to the project's own page. &lt;strong&gt;What it doesn't attempt&lt;/strong&gt; names the coverage gap. Builds of an engine now nest underneath it rather than taking a row alongside.&lt;/p&gt;

&lt;p&gt;The history chart split into two plots. One line couldn't be trusted on its own, because divergence falls whenever tests a target used to fail stop being run against it, which is how July rendered as an improvement.&lt;/p&gt;

&lt;p&gt;Tier bars use length for coverage and colour for divergence. Movement reads &lt;code&gt;4.9pp less&lt;/code&gt; instead of &lt;code&gt;-4.9pp&lt;/code&gt;. Every heading on the prose pages has an id you can link to.&lt;/p&gt;

&lt;p&gt;The badges were still publishing the correctness percentage the board had retired, so a badge could disagree with the table it linked to. They show the grade now, on the same URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Corrections
&lt;/h2&gt;

&lt;p&gt;The methodology page used to claim that measuring over the whole suite stopped a thin emulator posting a perfect score. It doesn't. Zero fails is 0.0% at any coverage, which is exactly why the grade caps on coverage instead.&lt;/p&gt;

&lt;p&gt;The baseline row was overstating itself. Real AWS runs in three passes and only the main one reached the published artefact, so the row claimed 998 of 998 on a run that recorded 981. The passes are merged before scoring now, and the row stays pinned and says so until all three report.&lt;/p&gt;

&lt;p&gt;The suite counts its own tests. Until this release "the whole suite" meant whichever target had run the most tests, which put one of the measured things in charge of everyone else's denominator. There's a generated manifest of all 1,054 now, and CI fails if it drifts from the tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks
&lt;/h2&gt;

&lt;p&gt;JSON consumers. The data endpoints jump two schema versions. Anything still on 2 gets both breaks at once, and there are four of them.&lt;/p&gt;

&lt;p&gt;The dangerous ones are &lt;code&gt;movement.state&lt;/code&gt; and &lt;code&gt;movement.delta&lt;/code&gt;. State's values changed from &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; to &lt;code&gt;improved&lt;/code&gt;/&lt;code&gt;regressed&lt;/code&gt;, so the old names still parse and now mean the opposite direction. Delta inverts the same way without changing shape, so the sign means the opposite of what it did and nothing in the data says so.&lt;/p&gt;

&lt;p&gt;The other two announce themselves. A tier carries &lt;code&gt;divergence&lt;/code&gt;, &lt;code&gt;coverage&lt;/code&gt; and &lt;code&gt;correctness&lt;/code&gt; instead of a bare &lt;code&gt;pct&lt;/code&gt; and &lt;code&gt;value&lt;/code&gt;, and the whole-suite correctness percentage is &lt;code&gt;correctness&lt;/code&gt; rather than &lt;code&gt;total&lt;/code&gt;, which also named a raw count elsewhere in the same payload.&lt;/p&gt;

&lt;p&gt;Correctness is still published under its own name, so anything built on the old figure keeps working. It just isn't the headline any more.&lt;/p&gt;

&lt;p&gt;The full notes are in the &lt;a href="https://github.com/paritysuite/dynamodb-conformance/releases/tag/v3.0.0" rel="noopener noreferrer"&gt;3.0.0 release&lt;/a&gt;, the suite is at &lt;a href="https://github.com/paritysuite/dynamodb-conformance" rel="noopener noreferrer"&gt;paritysuite/dynamodb-conformance&lt;/a&gt;, and the board itself is at &lt;a href="https://paritysuite.org" rel="noopener noreferrer"&gt;paritysuite.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The tests and the scoring code are both in the repo. If something looks wrong, an issue is welcome.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Learn DynamoDB by running it - accesspatterns.dev</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Tue, 30 Jun 2026 21:20:59 +0000</pubDate>
      <link>https://dev.to/martinhicks/learn-dynamodb-by-running-it-accesspatternsdev-8jp</link>
      <guid>https://dev.to/martinhicks/learn-dynamodb-by-running-it-accesspatternsdev-8jp</guid>
      <description>&lt;p&gt;I've been building on DynamoDB since around 2015, and these days I build tools for it: &lt;a href="https://dynoxide.dev" rel="noopener noreferrer"&gt;dynoxide&lt;/a&gt;, a DynamoDB engine, and &lt;a href="https://nubo.sinovi.uk" rel="noopener noreferrer"&gt;Nubo&lt;/a&gt;, a native client. So I'm not neutral about it. It's the first database I reach for, and with reason - the operational overhead is close to nil, no connections to pool or instances to size, and it holds the same single-digit-millisecond reads whether the table has a thousand items or a billion. The data modelling is a craft, and a satisfying one.&lt;/p&gt;

&lt;p&gt;It's also one of the harder databases to learn, and that's the part I keep coming back to. DynamoDB punishes the instincts you bring from SQL. You don't normalise and join at read time; you work out the questions your app will ask first, and shape the data around those access patterns, until one table answers all of them. It's a real shift in how you think, and it's where a lot of people bounce off - it feels backwards right up until it clicks.&lt;/p&gt;

&lt;p&gt;The people who teach it best all teach it the same way. &lt;a href="https://www.dynamodbbook.com/" rel="noopener noreferrer"&gt;Alex DeBrie's The DynamoDB Book&lt;/a&gt;, the &lt;a href="https://arc.codes/" rel="noopener noreferrer"&gt;arc.codes&lt;/a&gt; team's examples, Rick Houlihan's re:Invent talks - the legendary ones, where he models half a dozen access patterns onto a single table at a hundred miles an hour - none of them hand you rules to memorise. They show you patterns and make you run them. I learned a lot of my DynamoDB from all three, and it stuck because I was building as I went.&lt;/p&gt;

&lt;p&gt;That last part is the bit that's hard to come by on your own. Reading about an access pattern and having it in your fingers are different things, and to run one - build the table, write the items, fire the query and see what comes back - you need an AWS account, or a local emulator installed and seeded. Enough friction that plenty of people read about single-table design without ever building one.&lt;/p&gt;

&lt;p&gt;There was a second thing pulling the same way. dynoxide had &lt;a href="https://dev.to/articles/dynoxide-0100-browser-and-docker"&gt;learned to run in the browser&lt;/a&gt; only last month, compiled to WebAssembly with no server behind it, and it was a preview I didn't fully trust. What it needed was a real, demanding consumer: something that would lean on the wasm engine hard enough to find where it broke, not a handful of tests that politely agreed with it.&lt;/p&gt;

&lt;p&gt;Both of those pointed at the same project, and &lt;a href="https://accesspatterns.dev" rel="noopener noreferrer"&gt;accesspatterns.dev&lt;/a&gt; is what came out of it: a place to learn DynamoDB that also happens to be a working app sat on the engine all day. That's where the past month went, and why &lt;a href="https://dev.to/articles/dynoxide-0110-wasm-on-npm"&gt;0.11.0&lt;/a&gt; took as long as it did - building on it in earnest is what turned up the bugs that release fixed.&lt;/p&gt;

&lt;p&gt;What you get out of it is the thing I wanted from the start: the friction is gone. Open the page and the engine is already there with a table in it. Query one customer's orders and you get exactly those back, because in DynamoDB the key is the address; run the same question as a scan and it reads the whole table and throws most of it away. Same answer, very different work, and you've seen it for yourself rather than taken it on trust.&lt;/p&gt;

&lt;p&gt;There's more to it than that, though. A course of about twenty lessons takes you from your first &lt;code&gt;GetItem&lt;/code&gt; to single-table design, each one something you build and try rather than just read. Beyond it sit a handful of worked models - a game leaderboard, a SaaS multi-tenant table - to load up and pick apart, closer to what you'd actually ship than the usual textbook example. And underneath everything is a full console: assemble any operation, fire it at the table, and lift the request straight out as JavaScript SDK v3, boto3 or AWS CLI code for your own app.&lt;/p&gt;

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

&lt;p&gt;It all runs on dynoxide compiled to WebAssembly, in a Web Worker in your tab: the same engine that backs the native build, the same SQL underneath, now &lt;a href="https://dev.to/articles/dynoxide-0110-wasm-on-npm"&gt;packaged on npm&lt;/a&gt; as the thing accesspatterns.dev depends on. There's no backend, so every query, scan and write runs client-side against a SQLite-backed store. The database is in the tab, which is why it needs no account and nothing to install.&lt;/p&gt;

&lt;p&gt;It's a preview. The wasm build doesn't yet run against the &lt;a href="https://dev.to/articles/dynoxide-conformance-suite"&gt;conformance suite&lt;/a&gt; that holds dynoxide's native build to real DynamoDB, so treat what you see as illustrative rather than authoritative. Building accesspatterns.dev on it is what's been hardening it; getting the wasm engine onto that suite is the next job, and the day it's there is the day the preview label comes off.&lt;/p&gt;

&lt;p&gt;It's early, and the part I most want to grow is that library of models. I'd rather it weren't only mine. A shared, runnable catalogue of how people actually model their own problems on DynamoDB would be worth far more than my handful of examples, and that's the direction I want to take it. There's no way to submit one yet, but if you've got a pattern you'd want in there, tell me - and if something behaves wrong, tell me that too. It's the same dogfooding that got the engine this far.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>database</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How close is your DynamoDB emulator to AWS?</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Wed, 27 May 2026 14:21:30 +0000</pubDate>
      <link>https://dev.to/martinhicks/how-close-is-your-dynamodb-emulator-to-aws-1foo</link>
      <guid>https://dev.to/martinhicks/how-close-is-your-dynamodb-emulator-to-aws-1foo</guid>
      <description>&lt;p&gt;My DynamoDB conformance suite has a site now: &lt;a href="https://dynamodb-conformance.org" rel="noopener noreferrer"&gt;dynamodb-conformance.org&lt;/a&gt;. Eight emulators, 684 tests, scored against live AWS DynamoDB.&lt;/p&gt;

&lt;p&gt;The README only ever showed a single snapshot, frozen at whenever I last updated the table. The site shows everything around that snapshot. The homepage is the standings - every target ranked by how closely it matches real DynamoDB, with DynamoDB itself pinned at the top on a flat 100%. Each emulator gets its own page with run-over-run history - not just where a score landed, but where it moved and which tests pushed it. There's a support matrix that breaks every operation down target by target, and a runs archive going back through each scoring pass. Want to know whether LocalStack handles transactions, or where DynamoDB Local diverges on error wording? It's a couple of clicks now, not a repo checkout.&lt;/p&gt;

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

&lt;p&gt;Every test runs against live AWS DynamoDB first. Whatever the real thing does is recorded as the expected answer, and an emulator only passes if it gives that same answer. The ground truth is never my reading of the docs - it's what DynamoDB actually returned when I asked it.&lt;/p&gt;

&lt;p&gt;The results split into three tiers, because one number hides too much. &lt;strong&gt;Core&lt;/strong&gt; is the everyday stuff: CRUD, queries, scans, batch operations. &lt;strong&gt;Complete&lt;/strong&gt; adds the documented-but-less-common features like transactions, PartiQL, TTL and streams. &lt;strong&gt;Strict&lt;/strong&gt; is the fiddly end - validation ordering, exact error wording, API limits, legacy shapes. A gap in Core breaks your app. A gap in Strict breaks the CI test that asserts on an error message.&lt;/p&gt;

&lt;p&gt;Everything is checked through the standard AWS SDK against the target's HTTP endpoint. Nothing reaches inside the implementation. If your application would see it through the SDK, the suite checks it. If it wouldn't, it doesn't care.&lt;/p&gt;

&lt;p&gt;And the suite grows. New tests land most weeks, so a score can move because the target changed or because I added coverage that exposed an old gap. A high score only means the target passes the tests that exist; behaviours the suite doesn't cover yet are blind spots, not passes. The &lt;a href="https://dynamodb-conformance.org/methodology" rel="noopener noreferrer"&gt;methodology page&lt;/a&gt; has the full version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this run shows
&lt;/h2&gt;

&lt;p&gt;The figures below come from the run dated 26 May 2026 - the site always shows the current ones.&lt;/p&gt;

&lt;p&gt;Take LocalStack. It sits at 88% overall, which reads as middling until you split it: 98.6% on Core, 68.7% on Strict. That's not a mediocre emulator, it's a genuinely good one for everyday work that comes apart on error fidelity. The headline number flattens two completely different shapes of gap into one figure, which is precisely why the tiers exist. DynamoDB Local lands in almost exactly the same place - 97.7% Core, 69.2% Strict - which is no coincidence: LocalStack runs it under the hood. Same engine, same gaps, right down to the tier split.&lt;/p&gt;

&lt;p&gt;This run also shows the suite breathing. It grew by 29 tests this pass, and the biggest movers all went &lt;em&gt;down&lt;/em&gt; - Floci off 1.3 points, Dynalite 1.2, ExtendDB 1.1. Nothing regressed. The new tests just went looking where the old ones hadn't. That's the suite doing its job: coverage sharpens, scores dip, targets fix the gaps and climb back.&lt;/p&gt;

&lt;p&gt;One result is worth singling out. Ask real DynamoDB for item collection metrics on a write with &lt;code&gt;ReturnItemCollectionMetrics: SIZE&lt;/code&gt; and it hands them straight back. Ask DynamoDB Local, LocalStack, Dynalite or Ministack and you get silence - a documented response field they simply don't implement, AWS's own emulator included. Dynoxide, ExtendDB and Floci return it correctly. You'd never know unless something was checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;The whole thing is Apache-2.0 and &lt;a href="https://github.com/nubo-db/dynamodb-conformance" rel="noopener noreferrer"&gt;every test is in the repo&lt;/a&gt;. Anything that speaks the DynamoDB HTTP API can be scored, so if you maintain an emulator, clone it, point it at your endpoint and see what comes back. You'll almost certainly find something. If you'd rather just see your emulator on the board, there's a &lt;a href="https://github.com/nubo-db/dynamodb-conformance/issues/new?template=suggest-target.yml" rel="noopener noreferrer"&gt;suggest a target&lt;/a&gt; form.&lt;/p&gt;

&lt;p&gt;If you spot a behaviour the suite doesn't cover yet, send a PR. The single rule is that the test has to pass against real DynamoDB first - if AWS rejects it, the test is wrong, not the emulator. That rule is the whole point. Targets are measured against real DynamoDB, never against each other, so two emulators agreeing on the same wrong answer can't quietly make it the standard. The suite is meant to be an independent reference the whole field can trust, not something any one project owns. No emulator author, me included, gets to mark their own homework.&lt;/p&gt;

&lt;p&gt;And the field is genuinely getting good. Floci has come a long way since late April, from around 61% to the low 90s, with its Tier 3 score hauled up out of the twenties. ExtendDB only joined in late May and landed near the top straight away. It's an AWS-managed open-source project, Postgres-backed, that speaks the DynamoDB wire protocol. Worth a look.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dynoxide.dev" rel="noopener noreferrer"&gt;Dynoxide&lt;/a&gt;, my own engine, sits in there on the same terms as the rest: same tests, no favours. Floci and ExtendDB are a couple of points behind and still climbing, and the whole field is closing on real DynamoDB.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Filing my first security advisory</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Tue, 12 May 2026 15:02:50 +0000</pubDate>
      <link>https://dev.to/martinhicks/filing-my-first-security-advisory-1i3f</link>
      <guid>https://dev.to/martinhicks/filing-my-first-security-advisory-1i3f</guid>
      <description>&lt;p&gt;Yesterday I logged into GitHub and noticed a Dependabot alert sitting on dynoxide. DNS rebinding CVE in &lt;code&gt;rmcp&lt;/code&gt;, a transitive dependency. The alert had been raised a few days earlier - I just hadn't seen it, because I'd never set up email notifications for Dependabot on that repo. First lesson of the day, before the actual lesson of the day.&lt;/p&gt;

&lt;p&gt;I worked through the fix and published a GitHub Security Advisory once the patch release was out. The fix itself was the bit I knew how to do. The GHSA was new ground, and that's the part I want to write about, because if you maintain something with users and you've never filed one, the process is less daunting than I'd built it up to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The notification gap
&lt;/h2&gt;

&lt;p&gt;Worth dealing with this one first.&lt;/p&gt;

&lt;p&gt;Dependabot raises alerts on your repo's Security tab automatically. By default, GitHub doesn't email you about them - they appear on the dashboard and that's it. If you don't log into the affected repo regularly, you won't see them.&lt;/p&gt;

&lt;p&gt;The fix is at the repo level. Top right of the repo page, hit Watch → Custom, then tick Security alerts. You'll start getting emails for security events on that repo.&lt;/p&gt;

&lt;p&gt;I can see why this isn't the default. If you've forked a load of old projects you don't actually maintain, the last thing you want is an inbox full of alerts for repos that aren't yours. The cost of that default is that on the repos you &lt;em&gt;do&lt;/em&gt; maintain, you have to opt in deliberately. I hadn't.&lt;/p&gt;

&lt;p&gt;There's a second timing thing worth knowing about. Even with email notifications set up, there's a gap between the upstream CVE being published and your Dependabot alert firing. For mine, the upstream rmcp advisory was published on 29 April. My Dependabot alert came through about nine days later. GitHub's Advisory Database reviews advisories before they propagate, and Dependabot itself runs on a scan schedule. The delay is mostly outside your control. Knowing it exists at least means you don't panic when you discover the upstream advisory predates your own alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a GHSA actually is
&lt;/h2&gt;

&lt;p&gt;A GHSA is a structured record on your repo's Security tab. Affected package, affected version range, fixed version, severity, CVSS vector, weakness category, description. The structure matters because of what happens when you publish it: GitHub's advisory database picks it up, and every downstream project that depends on your package gets a Dependabot alert. Often an automated upgrade PR.&lt;/p&gt;

&lt;p&gt;In my case this was the whole point. The named CVE lives in &lt;code&gt;rmcp&lt;/code&gt;. Anyone with a direct rmcp dependency already had an alert from the upstream advisory. But dynoxide users have rmcp transitively - it's in the lockfile, not the manifest - and the upstream alert doesn't fire for them. Without a dynoxide-specific GHSA naming the affected version range of &lt;code&gt;dynoxide-rs&lt;/code&gt; and &lt;code&gt;dynoxide&lt;/code&gt;, nothing reaches those users automatically.&lt;/p&gt;

&lt;p&gt;The GHSA isn't ceremony. It's the actual distribution mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Order of operations
&lt;/h2&gt;

&lt;p&gt;Release first, advisory second.&lt;/p&gt;

&lt;p&gt;The advisory references the patched version. Dependabot's upgrade PRs for downstream projects will fail if that version doesn't exist on the registry yet. If you publish the GHSA before the release is live, you've broadcast "upgrade to a version that doesn't exist" to every project that depends on you.&lt;/p&gt;

&lt;p&gt;So: cut the release, wait for CI to push to the registries, verify the patched version is actually available, then publish the advisory. The save-as-draft option on the GHSA form is there for this reason.&lt;/p&gt;

&lt;p&gt;Last step is the social post, after opening the GHSA URL in a private window to check a logged-out viewer can actually see it. Until you hit publish, the advisory is only visible to repo admins and the URL returns nothing useful to anyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filing the form
&lt;/h2&gt;

&lt;p&gt;The form lives at &lt;code&gt;Security → Advisories → New draft security advisory&lt;/code&gt;. A few things that tripped me up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ecosystem.&lt;/strong&gt; The dropdown doesn't list "cargo" - it lists "Rust". I scrolled past it twice. The mapping is to crates.io under the hood. npm is named npm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package names.&lt;/strong&gt; Must match the registry exactly or Dependabot doesn't fire. GitHub validates this and shows a small "Package name found on Rust" confirmation when it's right. For dynoxide that's &lt;code&gt;dynoxide-rs&lt;/code&gt; (the Cargo crate, because the name &lt;code&gt;dynoxide&lt;/code&gt; was already taken) and &lt;code&gt;dynoxide&lt;/code&gt; (npm).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version ranges.&lt;/strong&gt; Cargo-style semver: &lt;code&gt;&amp;gt;= 0.9.3, &amp;lt; 0.9.13&lt;/code&gt;. I started at 0.9.3 because that's when the MCP HTTP transport landed. Earlier versions don't expose the vulnerable surface, so flagging them would just generate noise alerts for users on older versions who aren't actually affected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CVE.&lt;/strong&gt; The upstream CVE-2026-42559 already exists. There's a field for "I have an existing CVE ID" - use it. Don't request a new one for the same root cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CVSS.&lt;/strong&gt; This was the field I felt least sure about. There's an in-form calculator and I had a go at deriving my own vector for dynoxide's exposure context. I marked Attack Complexity as High because the rebinding chain felt fiddly to me. That was wrong - upstream marked it Low, and Low is right, because public DNS rebinding tooling makes the attack cheap to execute. My score came out at 7.5 instead of upstream's 8.8.&lt;/p&gt;

&lt;p&gt;The mistake was easy to make and easy to fix (GHSAs are editable after publishing), but it would have saved me both the agonising and the error to just match the upstream score in the first place. For a transitive-dep advisory where the exposure mechanism is essentially the same as upstream, divergent scores just confuse downstream readers. Match upstream unless you've got a strong reason not to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CWE.&lt;/strong&gt; Weakness category. The upstream advisory used CWE-346 (Origin Validation Error) and CWE-350 (Reliance on Reverse DNS Resolution). Same advice as CVSS: match upstream rather than reasoning from first principles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Description.&lt;/strong&gt; Markdown. I used Summary, Impact, Patches, Workarounds, References. The Workarounds section is the one I'd most strongly suggest including - if a user can't upgrade immediately, knowing how to mitigate is more useful than knowing the CVSS score. For dynoxide that was "use the stdio transport, don't pass &lt;code&gt;--http&lt;/code&gt;."&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The named CVE is DNS rebinding. You're running &lt;code&gt;dynoxide mcp --http&lt;/code&gt; so your coding agent can talk to a local DynamoDB. You visit a malicious page in another tab. The page's JavaScript spoofs the Host header in requests to &lt;code&gt;127.0.0.1:PORT/mcp&lt;/code&gt; and the server processes them. The attacker can call any tool the running dynoxide instance exposes, including writes.&lt;/p&gt;

&lt;p&gt;The rmcp 1.4+ fix is a Host-header allowlist: &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;127.0.0.1&lt;/code&gt;, &lt;code&gt;::1&lt;/code&gt;, anything else gets a 403. Upgrade rmcp, done.&lt;/p&gt;

&lt;p&gt;While I was reading the patched rmcp source to make sure I understood what the upgrade actually changed, I clocked something. The Host check closes rebinding. It doesn't close cross-origin CSRF.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://127.0.0.1:PORT/mcp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-cors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* call put_item */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser sends &lt;code&gt;Host: 127.0.0.1&lt;/code&gt; - legitimately, because that's the URL it's connecting to - and &lt;code&gt;Origin: https://evil.com&lt;/code&gt;. The Host check passes. There's no Origin check by default. The request goes through.&lt;/p&gt;

&lt;p&gt;A pedant's note: a no-cors POST with a JSON body actually can't set &lt;code&gt;Content-Type: application/json&lt;/code&gt; (browsers strip it down to &lt;code&gt;text/plain&lt;/code&gt;), so rmcp's protocol-level Accept and Content-Type checks would bounce this exact request before tool execution. The point still stands. Content-type validation isn't a security boundary - alternative request shapes that sidestep no-cors's restrictions (form POSTs, future MCP transport variants) reach the handler with valid framing. Origin is the right layer to enforce same-origin, not content-type.&lt;/p&gt;

&lt;p&gt;This isn't a flaw in the upstream fix. The CVE is narrowly scoped to DNS rebinding, and the fix closes it. Cross-origin CSRF is a different shape of attack on the same transport surface, and the upstream rmcp team have it tracked as a defence-in-depth follow-up. rmcp 1.6.0 already ships an &lt;code&gt;allowed_origins&lt;/code&gt; field on the same config - it just defaults to empty, which means "skip validation". Same pattern Host had before 1.4.&lt;/p&gt;

&lt;p&gt;So dynoxide 0.9.13 sets both lists explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="py"&gt;.allowed_hosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"localhost"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"::1"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="py"&gt;.allowed_origins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"http://localhost"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"http://127.0.0.1"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a regression test covering all three paths: loopback Host with no Origin (passes, because native MCP clients don't send Origin), foreign Host (403), foreign Origin (403). The test asserts on the rejection message, not just the status code, so a future rmcp change that returns 403 for some other reason can't keep the test green while reopening the vulnerability.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past-me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The advisory is a positive signal.&lt;/strong&gt; I was anxious about publishing it. Felt like it made dynoxide look amateur, like I was admitting fault. The opposite is true. Projects with zero advisories either have no users or aren't handling issues responsibly. Filing one is what well-run projects do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Match the upstream CVSS and CWEs.&lt;/strong&gt; Already covered above. The recalculation is defensible in theory but the consistent practice is simpler, faster, and less error-prone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn on the email notifications.&lt;/strong&gt; Worth the small ongoing noise on the repos you maintain.&lt;/p&gt;




&lt;p&gt;One thing the whole exercise sharpened for me: the HTTP transport doesn't authenticate callers at all. The Host and Origin allowlists defend against browsers, but anything on the same machine that can reach the loopback port can call any tool. Worth saying why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why no auth (yet)
&lt;/h2&gt;

&lt;p&gt;The MCP HTTP transport doesn't authenticate callers. That was a deliberate choice, not an oversight. dynoxide's main job is to be a small fast binary running in a CI job or on your own machine - both isolated environments where the only thing that can reach the loopback port is the process that launched it. The threat model is browser-borne attacks against that port, which is exactly what the Host and Origin allowlists handle. Adding token-based auth on top buys you very little: you'd be issuing yourself credentials to talk to yourself, and self-issued credentials tend to end up in dotfiles or pasted somewhere they shouldn't be.&lt;/p&gt;

&lt;p&gt;What's changed the calculation is the Docker image I've been working on. A container is a different deployment shape: it's something you can hand to another developer, drop into a shared environment, or expose on a network where the caller isn't the process that launched it. The trust boundary widens and the assumptions behind shipping without auth stop holding.&lt;/p&gt;

&lt;p&gt;So auth is the next piece of work. There's more design space here than I expected - the MCP spec is OAuth 2.1 or nothing, rmcp doesn't ship an auth hook of its own, and the path that actually fits a single-user local tool (a pre-shared bearer token wired in as a tower middleware) isn't really what the spec contemplates. More on that when it lands.&lt;/p&gt;




&lt;p&gt;dynoxide &lt;a href="https://github.com/nubo-db/dynoxide/releases/tag/v0.9.13" rel="noopener noreferrer"&gt;0.9.13&lt;/a&gt; is out. Upgrade if you're running &lt;code&gt;dynoxide mcp --http&lt;/code&gt; or &lt;code&gt;dynoxide serve --mcp&lt;/code&gt;. The advisory is at &lt;a href="https://github.com/nubo-db/dynoxide/security/advisories/GHSA-fvh2-gm75-j4j7" rel="noopener noreferrer"&gt;GHSA-fvh2-gm75-j4j7&lt;/a&gt; if you want a look at the finished form. If you've never filed a GHSA and you've got the option to file a draft on a private repo just to see the form, that's the cheapest way to take the unknown out of it before you have to do it for real.&lt;/p&gt;

</description>
      <category>security</category>
      <category>rust</category>
      <category>opensource</category>
      <category>dependabot</category>
    </item>
    <item>
      <title>Building a DynamoDB conformance suite</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Mon, 13 Apr 2026 16:09:34 +0000</pubDate>
      <link>https://dev.to/martinhicks/building-a-dynamodb-conformance-suite-28cb</link>
      <guid>https://dev.to/martinhicks/building-a-dynamodb-conformance-suite-28cb</guid>
      <description>&lt;p&gt;When I started building &lt;a href="https://dynoxide.dev" rel="noopener noreferrer"&gt;Dynoxide&lt;/a&gt; - a DynamoDB-compatible engine in Rust - I kept running into the same problem. I'd implement an operation, write tests against my understanding of DynamoDB's behaviour, and ship it. Then someone (usually me) would discover that real DynamoDB does something slightly different. A validation error with a different message. A condition expression that fails where I expected it to succeed. An edge case in number precision that I'd never considered.&lt;/p&gt;

&lt;p&gt;Every emulator ships with "DynamoDB compatible" and you're expected to take their word for it. I needed a way to answer a simple question: does Dynoxide actually behave like DynamoDB?&lt;/p&gt;

&lt;p&gt;No public conformance suite exists for DynamoDB - not from AWS, not from the community. &lt;/p&gt;

&lt;p&gt;So I built one.&lt;/p&gt;

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

&lt;p&gt;The principle is straightforward. Write a test. Run it against real DynamoDB. Record the result. That recorded result becomes the ground truth. Then run the same test against an emulator and compare.&lt;/p&gt;

&lt;p&gt;If the emulator's response matches DynamoDB's, the test passes. If it doesn't, the emulator has a conformance gap. No opinions, no guesswork about what the documentation means - just "does this behave the same way the real thing does?"&lt;/p&gt;

&lt;p&gt;I built it as a standalone project - &lt;a href="https://github.com/nubo-db/dynamodb-conformance" rel="noopener noreferrer"&gt;dynamodb-conformance&lt;/a&gt; - so anyone can clone it, point it at their own DynamoDB-compatible endpoint, and get results. It uses the AWS SDK v3 for TypeScript and vitest as the test runner. Nothing exotic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tiers
&lt;/h2&gt;

&lt;p&gt;Not all DynamoDB operations are equally important. If your emulator can't do a basic PutItem, nothing else matters. If it doesn't perfectly replicate the error message format for an obscure validation edge case, that's less critical.&lt;/p&gt;

&lt;p&gt;So the suite is split into three tiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1&lt;/strong&gt; covers core CRUD operations - CreateTable, PutItem, GetItem, UpdateItem, DeleteItem, Query, Scan, BatchGetItem, BatchWriteItem. These are the operations every application uses. If an emulator fails here, it's not usable for real development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2&lt;/strong&gt; covers advanced features - transactions, PartiQL, streams, tags, TTL, and table updates like billing mode changes. These are features that production applications rely on but that you might not hit in a simple prototype.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3&lt;/strong&gt; covers edge cases and error fidelity - validation ordering, exact error message formatting, API limits, and legacy API behaviour. The things that only matter when you need your emulator to be indistinguishable from the real service.&lt;/p&gt;

&lt;p&gt;This tiered structure means a result like "100% Tier 1, 95% Tier 2, 80% Tier 3" actually tells you something useful. It means core operations work perfectly, advanced features are mostly there, and edge cases have some gaps. A flat "92% overall" hides whether the failures are in PutItem or in obscure validation corner cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ground truth
&lt;/h2&gt;

&lt;p&gt;The key design decision was making DynamoDB itself the source of truth rather than my interpretation of the documentation.&lt;/p&gt;

&lt;p&gt;Every test in the suite has been run against a real DynamoDB table in eu-west-2. The expected values in the tests aren't what I &lt;em&gt;think&lt;/em&gt; DynamoDB should return based on reading the docs - they're what DynamoDB &lt;em&gt;actually&lt;/em&gt; returns. This matters more than you'd expect. DynamoDB's documentation is good, but it doesn't cover every edge case. Sometimes the only way to know what DynamoDB does is to try it and see.&lt;/p&gt;

&lt;p&gt;A scheduled CI job runs the full suite against real DynamoDB weekly, so if AWS changes something - a new validation, a different error message, a behavioural tweak - the ground truth stays current. The suite can't go stale the way a set of hand-written expected values would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Tier 1 (267)&lt;/th&gt;
&lt;th&gt;Tier 2 (93)&lt;/th&gt;
&lt;th&gt;Tier 3 (166)&lt;/th&gt;
&lt;th&gt;Overall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB (eu-west-2)&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;526 / 526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dynoxide&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;526 / 526&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LocalStack&lt;/td&gt;
&lt;td&gt;98.9%&lt;/td&gt;
&lt;td&gt;95.7%&lt;/td&gt;
&lt;td&gt;81.9%&lt;/td&gt;
&lt;td&gt;489 / 526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB Local&lt;/td&gt;
&lt;td&gt;98.9%&lt;/td&gt;
&lt;td&gt;90.3%&lt;/td&gt;
&lt;td&gt;81.9%&lt;/td&gt;
&lt;td&gt;484 / 526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynalite&lt;/td&gt;
&lt;td&gt;98.1%&lt;/td&gt;
&lt;td&gt;10.8%&lt;/td&gt;
&lt;td&gt;92.8%&lt;/td&gt;
&lt;td&gt;426 / 526&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dynoxide passes every test. 526 out of 526, across all three tiers.&lt;/p&gt;

&lt;p&gt;DynamoDB Local - AWS's own emulator - fails 42. LocalStack fails 37. Dynalite, which hasn't been actively maintained for a while, fails 57 (and that Tier 2 number of 10.8% tells you it's missing entire feature categories like transactions and PartiQL).&lt;/p&gt;

&lt;p&gt;I want to be careful about what this means and what it doesn't.&lt;/p&gt;

&lt;p&gt;526 tests is a lot, but DynamoDB is a massive service. There are aspects of it that the suite doesn't cover - DAX compatibility, on-demand capacity semantics, cross-region replication, fine-grained IAM condition keys. "100% conformance on 526 tests" is accurate. "100% DynamoDB compatible" is not, and I wouldn't claim it.&lt;/p&gt;

&lt;p&gt;What the results do tell you is that for the operations and behaviours the suite covers - which include everything most applications use day to day - Dynoxide matches DynamoDB exactly. Not approximately. Exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What DynamoDB Local gets wrong
&lt;/h2&gt;

&lt;p&gt;Some of DynamoDB Local's 42 failures are genuinely surprising. This is AWS's own emulator. You'd expect it to be the gold standard for local DynamoDB development. In practice, it has real gaps across all three tiers.&lt;/p&gt;

&lt;p&gt;Three of its Tier 1 failures are in ItemCollectionMetrics - it doesn't return them for PutItem, DeleteItem, or UpdateItem even when you ask for them with &lt;code&gt;ReturnItemCollectionMetrics: SIZE&lt;/code&gt;. That's not an edge case. That's a documented feature of the API that silently does nothing.&lt;/p&gt;

&lt;p&gt;In Tier 2, the entire tagging API is broken. All eight tag-related tests fail - TagResource, UntagResource, ListTagsOfResource. If you're writing infrastructure-as-code that tags tables and you're testing against DynamoDB Local, none of that is being validated. It also gets PartiQL INSERT semantics wrong - it treats INSERT as an upsert when real DynamoDB correctly rejects an INSERT on an existing item.&lt;/p&gt;

&lt;p&gt;The bulk of the failures (30) are in Tier 3 - validation ordering and error message formatting. DynamoDB Local returns different errors than production for the same bad request. This might sound academic, but if you're writing error handling code against DynamoDB Local, it might not work the same way against the real thing. You'll get the right error &lt;em&gt;type&lt;/em&gt; but the wrong message content, or the validations will fire in a different order.&lt;/p&gt;

&lt;p&gt;These are the kind of differences you only discover when you have a conformance suite to catch them. Without one, they hide as production bugs that are impossible to reproduce locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother?
&lt;/h2&gt;

&lt;p&gt;I built this because I needed it for Dynoxide. I wanted confidence that the thing I was building actually worked the way DynamoDB works, not the way I assumed it works. But the suite is useful beyond that.&lt;/p&gt;

&lt;p&gt;If you're choosing between DynamoDB emulators for your development workflow, you can run the suite yourself and compare. The numbers above aren't marketing claims - they're reproducible results from a public repository that anyone can verify.&lt;/p&gt;

&lt;p&gt;If you maintain a DynamoDB emulator or compatibility layer, you can use it to find and fix gaps. Point it at your endpoint, see what fails, fix the failures. The tiered structure tells you which failures matter most.&lt;/p&gt;

&lt;p&gt;And if you're using DynamoDB Local in your CI pipeline and wondering why a test passes locally but fails against real DynamoDB - the conformance suite might tell you why. The 42 things DynamoDB Local gets wrong are 42 potential sources of exactly that kind of bug.&lt;/p&gt;

&lt;p&gt;526 tests is solid coverage, but there are DynamoDB behaviours the suite doesn't exercise yet. If you've hit a case where an emulator diverges from real DynamoDB and you think it should be covered, open a PR. The only requirement is that the test passes against real DynamoDB - include the results artifact from a run against your own AWS account and I'll verify it against the ground truth pipeline. More tests make the suite more useful for everyone, not just Dynoxide.&lt;/p&gt;

&lt;p&gt;The suite is at &lt;a href="https://github.com/nubo-db/dynamodb-conformance" rel="noopener noreferrer"&gt;github.com/nubo-db/dynamodb-conformance&lt;/a&gt;. Clone it, run it, check the results for yourself.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>dynamodb</category>
      <category>aws</category>
      <category>testing</category>
    </item>
    <item>
      <title>Introducing Dynoxide: a fast, embeddable DynamoDB engine</title>
      <dc:creator>Martin Hicks</dc:creator>
      <pubDate>Thu, 02 Apr 2026 21:02:51 +0000</pubDate>
      <link>https://dev.to/martinhicks/introducing-dynoxide-a-fast-embeddable-dynamodb-engine-15ni</link>
      <guid>https://dev.to/martinhicks/introducing-dynoxide-a-fast-embeddable-dynamodb-engine-15ni</guid>
      <description>&lt;p&gt;I've been working with DynamoDB for the best part of a decade. It's my default database for most things I build at &lt;a href="https://sinovi.uk" rel="noopener noreferrer"&gt;Si Novi&lt;/a&gt;, and I genuinely like it. The data modelling is satisfying once it clicks, the operational overhead is basically zero, and it scales without you having to think about it.&lt;/p&gt;

&lt;p&gt;Last year, Si Novi started building &lt;a href="https://nubo.sinovi.uk" rel="noopener noreferrer"&gt;Nubo&lt;/a&gt;, a native DynamoDB client for macOS and iPadOS, with Windows and Linux on the way. One of the features we wanted from the start was a built-in sandbox. A local DynamoDB instance running on-device that you could experiment with, no AWS credentials needed. Create tables, insert data, test access patterns. All offline. All on your machine. Maybe even on an iPad.&lt;/p&gt;

&lt;p&gt;The problem was: how do you ship that?&lt;/p&gt;

&lt;p&gt;DynamoDB Local's &lt;a href="https://aws.amazon.com/dynamodb/dynamodblocallicense/" rel="noopener noreferrer"&gt;licence&lt;/a&gt; explicitly prohibits redistribution. You can't bundle it inside another application or sublicence it. Even setting the licence aside, the developer experience would have been awful. Asking users to separately download and install a Java-based emulator, with Docker or a JVM as a prerequisite, just to use one feature in your app? That's not a sandbox. That's homework.&lt;/p&gt;

&lt;p&gt;And on iPadOS there's no path at all. No JVM. No Docker. Nothing.&lt;/p&gt;

&lt;p&gt;So I started building something new. A DynamoDB-compatible engine in Rust, backed by SQLite, that compiles to a single native binary. Something I could embed directly into Nubo as a library.&lt;/p&gt;

&lt;p&gt;That's how Dynoxide started.&lt;/p&gt;

&lt;h2&gt;
  
  
  From library to tool
&lt;/h2&gt;

&lt;p&gt;The first version did exactly what I needed: a local DynamoDB that Nubo could bundle as its sandbox. Job done, in theory. But the thing that surprised me was just how small and fast it turned out to be.&lt;/p&gt;

&lt;p&gt;A ~3 MB download (~6 MB on disk). Under 5 MB of RAM at idle. Cold startup in about 15 milliseconds, compared to over two seconds for DynamoDB Local. Numbers like that change what's practical. Instead of running one shared DynamoDB instance in your CI pipeline, you could spin up a fresh one per test. Isolated state. No cleanup step. No flaky tests from leftover data. Tear it down when you're done and the overhead is essentially nothing.&lt;/p&gt;

&lt;p&gt;That realisation shifted what Dynoxide was becoming. It wasn't just infrastructure for Nubo any more. It was a tool in its own right.&lt;/p&gt;

&lt;p&gt;The same properties that made it good for CI made it a natural fit for agentic development. A coding agent that needs to create tables, write data, or test queries can do all of that through Dynoxide's built-in MCP server. 34 tools, no setup required. Run &lt;code&gt;dynoxide mcp&lt;/code&gt; and your agent has a full DynamoDB environment to work with.&lt;/p&gt;

&lt;p&gt;And then I looked at how many Node.js projects still depend on &lt;a href="https://github.com/mhart/dynalite" rel="noopener noreferrer"&gt;dynalite&lt;/a&gt; for local DynamoDB. I owe a lot to dynalite. Using it through &lt;a href="https://arc.codes" rel="noopener noreferrer"&gt;arc.codes&lt;/a&gt; and Architect is what turned DynamoDB from a database I'd reach for occasionally into the one I'd reach for first. Having a fast local emulator that just worked changed how I thought about building with DynamoDB. It made the feedback loop tight enough that I actually wanted to experiment. But dynalite hasn't seen updates in a while and newer DynamoDB features like transactions and streams aren't covered. Dynoxide supports both, along with the rest of the API surface. With an npm package shipping platform-specific binaries (the same approach esbuild and Biome use), it works as a drop-in replacement. Same workflow, one line change in your &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;p&gt;Dynoxide implements the DynamoDB API as a translation layer in Rust, storing data in SQLite and compiling to a native binary with no runtime dependencies. DynamoDB Local takes the same general approach (SQLite as the storage engine is &lt;a href="https://dev.to/aws-heroes/dynamodb-local-in-docker-25i"&gt;well documented&lt;/a&gt;) but ships as a Java application with a JVM dependency and a restrictive licence.&lt;/p&gt;

&lt;p&gt;Because SQLite is an embedded database, Dynoxide doesn't have to run as a standalone server. It can be linked directly into another application, which is exactly how Nubo uses it. Encrypt the database file with SQLCipher and you've got a portable, encrypted, DynamoDB-compatible data store. Nubo uses this for its local cache: data you've previously accessed from AWS is kept in an encrypted database on-device, so it loads instantly and works offline.&lt;/p&gt;

&lt;p&gt;To make sure Dynoxide actually behaves like the real thing, I built a &lt;a href="https://github.com/nubo-db/dynamodb-conformance" rel="noopener noreferrer"&gt;conformance test suite&lt;/a&gt;. 526 tests, all validated against real DynamoDB on AWS. Every emulator gets the same suite. Dynoxide passes all 526. DynamoDB Local manages 92%. LocalStack gets 93%. Dynalite hits 81%.&lt;/p&gt;

&lt;p&gt;The suite is public. Anyone can run it and verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get started
&lt;/h2&gt;

&lt;p&gt;Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;nubo-db/tap/dynoxide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm (drop-in dynalite replacement):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; dynoxide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cargo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;dynoxide-rs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then just run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dynoxide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full documentation is at &lt;a href="https://dynoxide.dev" rel="noopener noreferrer"&gt;dynoxide.dev&lt;/a&gt;, and the source is on &lt;a href="https://github.com/nubo-db/dynoxide" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Dynoxide is the foundation. But the thing I'm building on top of it is what I really want to talk about next. &lt;a href="https://nubo.sinovi.uk" rel="noopener noreferrer"&gt;Nubo&lt;/a&gt; is a native DynamoDB client for macOS and iPadOS, and I think people who work with DynamoDB day to day will love it. If that sounds interesting, there's a mailing list at &lt;a href="https://nubo.sinovi.uk" rel="noopener noreferrer"&gt;nubo.sinovi.uk&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Dynoxide stands on its own though. If you're running DynamoDB Local in CI and wondering why your pipeline is slow, or you're still using dynalite and need coverage for newer API features like transactions and streams, give it a try. I'd love to know what you think.&lt;/p&gt;

&lt;p&gt;Si Novi will be at AWS Summit London on April 22nd. If you're there and want to talk DynamoDB, come and find me.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>rust</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
