Aurora vs DynamoDB: Why We Switched, and What It Cost Us
Our database wasn’t slow. It was in the way. We run a multi-tenant SaaS web app on Amplify Gen 2, with AppSync for the API and Lambda for the business logic. It started on Aurora Postgres, which felt like the safe choice. Queries were fine. The trouble sat around the database instead, in shared environments, network setup, and business rules that ran at the wrong moment.
In August 2026, we moved the whole data layer to DynamoDB. In this article, we will walk through why we switched, how we rebuilt it, what improved, what broke, and when Aurora is still the better choice.
What Changes Between Aurora and DynamoDB?
Here are the basics before the story.
Most of this article comes back to two rows of that table, the one about queries and the one about rules.
Aurora lets you ask any question later. DynamoDB asks you to decide your questions now.
Where We Started: The Aurora Setup
AppSync talked to Aurora through a schema file generated by npx ampx generate schema-from-database. Ours hardcoded the VPC, subnet and security-group IDs. So every developer pointed at the same cluster, inside the same VPC, using the same set of database roles.
The Lambdas ran inside that VPC too. Each one needed network permissions, SQL secrets, and SSM endpoints to reach its configuration from inside the private network.
The schema itself lived in about 20 SQL migration files. Business rules lived there too, written as database triggers.
Why We Switched
None of this showed up on a latency graph. It showed up in how hard the system was to work on.
- No isolated environments: With one shared cluster, a schema change from one developer affected every other developer the moment it ran.
- Cold starts that looked like firewalls: The cluster paused when idle, and the first connection afterward timed out. The error looked like a firewall problem, not a sleeping database. AWS documents a typical resume time of about 15 seconds, and 30 seconds or more after a pause longer than a day.
- A fragile setup: Connection strings broke whenever the password contained special characters, and Windows developers had to install Postgres tools locally.
- Triggers that ran too late: Our rules were AFTER triggers, which fire once the row is already written. That made them good at reacting and bad at refusing. The balance check could race under READ COMMITTED, where two concurrent writes both read the same balance and both pass. Deleting a record could also leave its linked claim record behind.
- A trust gap: The server accepted user IDs sent by the client instead of taking them from the login token.
Why Not Just Fix Aurora?
It’s a fair question, and both obvious fixes exist. A cluster per developer would solve isolation, but every developer would then carry their own cluster, secrets, and resume delay, and the generated schema still pins one network configuration. The RDS Data API puts an HTTPS endpoint in front of the cluster, so Lambdas no longer need to sit in the VPC. Neither fix changes when a rule runs or where the user ID comes from. Those two problems needed new code, whichever database sat underneath.
How We Rebuilt It on DynamoDB
The goal was to change the storage without changing what the frontend saw.
Same API, New Storage
We kept all 31 models with the same names and fields, so the 96 existing frontend calls still compiled. That one decision lowered the risk of the switch more than anything else we did.
Uniqueness moved into the key. Of 22 UNIQUE constraints, 15 became part of a composite primary key. That also fixed two live bugs where a value was unique across all tenants instead of within one organization.
// amplify/data/resource.ts (simplified, authorization omitted)
const schema = a.schema({
Member: a
.model({
organizationId: a.id().required(),
email: a.email().required(),
role: a.string(),
})
.identifier(['organizationId', 'email']),
});.identifier() makes the pair the primary key, and Amplify's generated create mutation refuses a key that already exists. A raw PutItem would quietly replace the item instead, which matters again below.
We added 20 GSIs for the reads we knew about. One is a sparse index that only holds open sessions, because DynamoDB leaves out any item missing the index key. A background job that used to scan the full table now queries it.
Rules Before the Write
Every trigger became a server-side custom mutation. Each one checks its rules before writing and reads the user from the verified token, never from the arguments (our authentication vs authorization guide covers why).
The balance check is now one TransactWriteItems call with a condition. DynamoDB checks the condition and applies the change atomically, so two concurrent requests can’t both spend the same balance. If the condition fails, nothing in the transaction is written.
const userId = event.identity.sub; // from the token, never from args
await ddb.send(new TransactWriteCommand({
TransactItems: [
{
Update: {
TableName: ACCOUNTS_TABLE,
Key: { accountId },
UpdateExpression: 'SET balance = balance - :amount',
ConditionExpression: 'balance >= :amount',
ExpressionAttributeValues: { ':amount': amount },
},
},
{
Put: {
TableName: LEDGER_TABLE,
Item: { accountId, entryId, amount, createdBy: userId },
ConditionExpression: 'attribute_not_exists(entryId)',
},
},
],
}));Special Cases, Designed Around Their Reads
The document-signing module uses a single-table design. Everything that belongs to one document lives in one partition, so loading a document is one Query instead of a six-way join.
Get Vidura Senevirathne’s stories in your inbox
Join Medium for free to get updates from this writer.
The audit log is append-only, enforced by IAM. Its writer role can callPutItem, never UpdateItem or DeleteItem. IAM can't stop a PutItem from replacing an item with the same key, though, so every write also carries attribute_not_exists. That condition anchors a hash chain, with capped retries when two writers race for the same position.
Rate limiting moved from WAF to a TTL-based counter table. TTL deletion runs in the background and can lag, so a limiter like this has to compare timestamps rather than trust that expired items are gone.
What Got Better
The wins showed up in daily work.
- Isolated sandboxes: Every developer gets their own, with just two sign-in keys to set up.
- Less to run: No VPC, cluster, connection strings, or SQL secrets to manage.
- Lower idle cost: We pay per request; nothing runs when idle, and dropping WAF and CloudTrail saves about $11 to $20 a month.
- Rules at write time: Every rule is checked before the write, and the user comes from the token.
- Predictable reads: Planned reads are fast and stay predictable.
Real Problems After the Move
This is the section we wish someone had written for us. Several of these bugs returned something that looked like success.
1. List pages scanned the whole table
Some lists called list with a filter. In Amplify Data that runs aScan, which reads items first and filters afterward, so its cost grows with the table rather than with the result. We moved them to index queries with paging.
// Before: Scan, filtered after the read
client.models.Session.list({ filter: { organizationId: { eq: orgId } } });
// After: Query on a GSI, one page at a time
client.models.Session.listSessionByOrganizationId(
{ organizationId: orgId },
{ limit: 50, nextToken },
);2. A job returned nothing, though matches existed
DynamoDB applies Limit to the items it reads, then filters them. So a page can hold zero matches while LastEvaluatedKey says more exist. Keep paging until that key is gone, or better, design an index that makes the filter unnecessary.
3. Revoke ran, and nothing was revoked
We stored revoked_at: null on new items. In DynamoDB, an attribute set to null still exists, with the NULL type. So every attribute_not_exists(revoked_at) check evaluated to false, and revoking silently did nothing.
4. One field name broke the hash chain
One code path wrote headSeq and another read head_seq. DynamoDB has no schema to object, so the hash-chain checkpoints broke without an error. Counting this one, we found 13 bugs after the move that our 263 unit tests had missed.
5. The sync finished, and users saw “failed”
AppSync caps request execution time at 30 seconds. A long sync kept running and finished its work, but the request had already timed out. Work that can pass 30 seconds has to be started by the request and finished somewhere else.
6. Deploys outgrew CloudFormation
A nested stack hierarchy can create, update, or delete at most 2,500 resources in one CloudFormation operation. Our backend reached about 2,995, so a fresh deploy now takes two steps. (IAM inline policy size is another ceiling large Amplify backends hit.)
Permissions added a quieter delay. Changes can take minutes to take effect after a deploy, so a test run straight afterward can fail on access that’s already granted.
Limitations and Trade-offs
DynamoDB asks for decisions up front, and some of its costs never go away.
- Access patterns come first: A new query usually means a new GSI.
- No joins or ad hoc SQL: Reporting and analytics get harder.
- GSIs are eventually consistent: A read right after a write can miss it.
- Denormalized counters drift: You need checks that catch it.
- Size limits: 400 KB per item, and a transaction holds up to 100 items or 4 MB.
- Security trade-offs we accepted: The rate limiter won’t stop a large DDoS the way WAF would, and the key-usage audit now runs weekly instead of in real time.
How to Choose, and What We’d Tell You
Choose Aurora when reporting is heavy, when new queries show up often, or when your data is full of relationships you need to join.
Choose DynamoDB when you know your access patterns, your stack is already serverless, your traffic rises and falls, or your developers need isolated environments.
The lessons we’d pass on are short. List your queries before you design a table. Never scan where a user is waiting. Treat null and missing as different values, because DynamoDB does. And run end-to-end checks, because unit tests can’t catch everything.
DynamoDB didn’t make things simpler. It moved the complexity from running servers to designing data, and for us that was the better place for it.











