Hey folks, I wanted to share a recent migration experience that might be useful for anyone weighing similar options. We've been running a fairly high-throughput telemetry ingestion layer on AWS using DynamoDB for years, but a broader company shift to Azure meant we needed to evaluate Azure Table Storage as a potential replacement. The goal was to maintain functionality while reducing cost, accepting some performance trade-offs if they were reasonable.
The "why" primarily came down to **cost structure**. Our DynamoDB table, with on-demand capacity, was costing us a significant amount due to the pattern of sporadic, high-volume writes. Azure Table Storage, with its simple pricing per transaction and storage, looked dramatically cheaper on paper. Here's a rough breakdown of our core configuration before and after:
**Previous (DynamoDB):**
- On-demand capacity mode
- Composite primary key (PartitionKey + SortKey)
- GSIs for two alternative query patterns
**New (Azure Table Storage):**
- PartitionKey & RowKey (similar to DynamoDB's PK/SK model)
- No secondary indexes (this is the big limitation)
- All data denormalized into single table patterns
The performance hit was measurable, but in our specific context, acceptable. The biggest difference isn't raw latency for a single operation—it's the lack of indexing flexibility.
**Latency Comparison (P99, same region):**
| Operation | DynamoDB | Azure Table Storage |
| :--- | :---: | :---: |
| Insert (single) | ~12ms | ~25ms |
| Query by PartitionKey | ~15ms | ~35ms |
| Query by PartitionKey & RowKey range | ~18ms | ~40ms |
Where we felt the pinch was in queries that previously used a GSI. Those now require a full table scan client-side or a re-architecting of the data model. We chose to denormalize, which increased storage by about 40% but kept queries fast.
The migration code wasn't too complex, but the mental model shift is important. Here's a snippet of our new query pattern in C#:
```csharp
// Querying within a partition for a time range (RowKey is timestamp-based)
TableQuery rangeQuery = new TableQuery()
.Where(TableQuery.CombineFilters(
TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, deviceId),
TableOperators.And,
TableQuery.GenerateFilterCondition("RowKey", QueryComparisons.GreaterThan, startTimeTicks)));
// You MUST handle continuation tokens for pagination
TableContinuationToken token = null;
do
{
var queryResult = await table.ExecuteQuerySegmentedAsync(rangeQuery, token);
entities.AddRange(queryResult.Results);
token = queryResult.ContinuationToken;
} while (token != null);
```
**Key Takeaways:**
* **Cost Savings:** We saw a ~65% reduction in monthly database costs. The savings were massive.
* **Performance:** Latency roughly doubled for direct key operations, but remained within our SLO for this service. Throughput scalability is simpler with DynamoDB.
* **Design Constraint:** The lack of secondary indexes forces a much stricter single-table design. If your access patterns are fluid, this can be a deal-breaker.
* **Tooling & Ecosystem:** DynamoDB's integration with AWS observability tools (X-Ray, advanced CloudWatch metrics) is more mature. We had to invest more in custom application-level metrics.
For us, the trade-off was worth it. The cost savings directly funded other projects, and our access patterns were predictable enough to model within Table Storage's constraints. If you're considering a similar move, I'd advise:
1. Thoroughly map *all* your query access patterns.
2. Prototype the denormalized table design first.
3. Benchmark with your actual workload, not just synthetic tests.
Would love to hear if others have navigated this path and what patterns you used to work around the indexing limits. Especially interested in strategies for gradually migrating query patterns without bringing down the service.
—Chris
Prod is the only environment that matters.