DynamoDB was the default for years. Not because it was the best fit for every workload, but because it was already there.

When everything else ran on AWS, DynamoDB was the path of least resistance. It scaled without capacity planning drama if you used on-demand mode. It integrated directly with Lambda. The SDK was familiar. But over time, the operational cost of maintaining DynamoDB across multiple products started to feel disproportionate to what the data layer was actually doing.

The DynamoDB tax

DynamoDB is powerful, but it comes with a design tax that compounds across a portfolio.

DynamoDB: per-product overhead

Table Design Doc

Key Schema Conventions

GSI Definitions

Attribute Mapping Code

IAM Policies

Capacity Monitoring

Backup Config

Single-table design is the accepted best practice for DynamoDB at scale. You model your access patterns up front, flatten everything into partition keys and sort keys, and use overloaded attributes and GSIs to support different query shapes. This works. But it also means your data model is shaped around the database’s constraints rather than around the domain.

For each product, that meant maintaining a table design document, a set of key schema conventions, GSI definitions, and the application-level code that translated between domain objects and DynamoDB’s item structure. Multiply that across several products and you are spending real time on data layer choreography.

On top of that, each table needed its own IAM policies, its own capacity monitoring, and its own backup configuration. None of it was hard. All of it was weight.

Why Bunny Database was a better fit

The main argument was colocation.

Bunny: colocated stack

Bunny CDN

Magic Container

Bunny Database

AWS: cross-service hops

CloudFront

API Gateway

Lambda

DynamoDB

If the runtime already lives on Bunny via Magic Containers, and the static layer already lives on Bunny via Bunny Storage, then having the database on the same platform means the entire stack is in one place. No cross-provider latency. No separate authentication flow. No additional SDK to maintain.

But the colocation story alone would not have been enough. What made Bunny Database practical was that it let me use a more natural data model.

Instead of forcing everything into partition key and sort key gymnastics, I could model the data in a shape that actually matched the domain. For products where the access patterns were straightforward, this meant simpler queries, fewer indexes, and less translation code between the application and the database.

What the migration looked like

The migration was product by product.

Bunny DatabaseMigration ScriptDynamoDBBunny DatabaseMigration ScriptDynamoDBScan table (paginated)Items in single-table formatTransform to target schemaStrip unused access patternsLoad batch with checkpointsConfirm writeVerify row counts match

For each product I started by mapping the existing DynamoDB access patterns back to what the application actually needed. In several cases, the single-table design had access patterns baked in that were no longer used or that existed only because the original design anticipated query shapes that never materialized.

Stripping those out simplified the migration target.

The data export was straightforward. Scan the DynamoDB table, transform the items from the single-table format into the target schema, and load them into Bunny Database. For products with small data volumes this was a one-shot script. For larger tables I ran it in batches with checkpoints.

The application-side changes were mostly about removing the DynamoDB SDK calls and replacing them with the Bunny Database client. The handler logic stayed the same. The data access layer got thinner because there was less translation between the domain model and the storage format.

Access patterns got simpler

This was the biggest practical win.

Bunny Database: same query

SELECT * FROM items

WHERE user_id = 123

ORDER BY created_at

DynamoDB: get user items by date

PK = USER#123

SK begins_with ITEM#

GSI1: date-sorted index

ProjectionExpression filter

Unpack overloaded attributes

On DynamoDB, a query like “get all items for this user sorted by date” required careful key design. The partition key had to be the user ID, the sort key had to include the date, and if you needed a secondary access pattern you added a GSI with a different key scheme.

On Bunny Database, the same query is just a query. Filter by user, order by date. No key design ceremony. No GSI management. No overloaded attributes.

For the products in this portfolio, where the access patterns are real but not exotic, this simplification removed an entire layer of indirection that was not paying for itself.

What I lost

DynamoDB Streams. On AWS, DynamoDB Streams gave you a change feed that could trigger Lambda functions when items were created, updated, or deleted. That was useful for event-driven patterns and cross-service synchronization.

After: Explicit events

Write item

Bunny DB

Emit event

Side effect

Before: DynamoDB Streams

Write item

DynamoDB

DynamoDB Stream

Lambda trigger

Side effect

Bunny Database does not have the same change feed model. For the products that relied on DynamoDB Streams, I replaced the pattern with explicit event emission from the application layer. When a handler writes data, it also publishes the event. This is more explicit and arguably easier to reason about, but it does mean the application is responsible for event consistency rather than the database.

I also lost the mature ecosystem of DynamoDB tooling: the local development emulator, the extensive CloudWatch integration, and the deep IAM policy granularity. For a portfolio where I am the operator and the developer, the tradeoff was worth it. For a large team with strict compliance requirements around data access auditing, DynamoDB’s IAM integration would be harder to replace.

The schema question

One thing that changed my thinking was how much time I had been spending on schema-free design that was not actually schema-free.

DynamoDB is schemaless at the database level, but in practice every application enforces its own schema in code. You end up with validation logic, type coercion, and attribute mapping that is functionally a schema but lives scattered across the application instead of in one place.

Moving to Bunny Database let me make the schema explicit at the database level. That meant the validation moved out of application code and into the data layer where it belongs. Less defensive coding in handlers. Fewer bugs from malformed items. Clearer contracts between the application and its data.

Colocation matters more than people think

The underrated part of this migration was how much simpler everything felt once the database was on the same platform as the runtime and the storage layer.

Bunny request path

Request

Bunny CDN

Magic Container

Bunny DB

AWS request path

Request

CloudFront

API Gateway

Lambda

DynamoDB

On AWS, a request might touch CloudFront, API Gateway, Lambda, and DynamoDB, each with its own latency profile, its own failure modes, and its own monitoring story. On Bunny, a request hits the container and the container talks to the database. Two hops instead of four.

That does not just improve latency. It simplifies debugging, reduces the number of things that can go wrong independently, and makes the operational posture of each product much easier to hold in your head.

For a solo operator running a real portfolio, being able to hold the whole stack in your head is not a luxury. It is a requirement.