Query Optimization
Optimize spatial query performance by choosing the right execution mode, precision level, and query patterns.
Execution Modes
Non-Paginated (pageSize = null)
All cell queries execute in parallel using Task.WhenAll:
- Latency: ~50ms regardless of cell count (single query time)
- Memory: All results loaded into memory
- Best for: Small to medium result sets (< 1000 items)
var result = await table.SpatialQueryAsync(
center: center,
radiusKilometers: 5,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: null // Non-paginated — fastest
);
Paginated (pageSize > 0)
Cells are queried sequentially in spiral order (closest to farthest):
- Latency: N × ~50ms (where N = cells queried for this page)
- Memory: One page in memory at a time
- Best for: Large result sets, infinite scroll, API pagination
var result = await table.SpatialQueryAsync(
center: center,
radiusKilometers: 50,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: 100 // Paginated — memory efficient
);
Spiral Ordering
Paginated queries use spiral ordering — cells are queried from closest to farthest from the search center. This ensures:
- Most relevant results (closest) appear in first pages
- Users typically only view first 1-2 pages
- Early termination saves DynamoDB costs
- Results are roughly sorted by distance
Precision Selection
Rule of thumb: Cell size should be 20-50% of your search radius.
Optimal cell size ≈ radius / 3
Cell Count Formula
cellCount ≈ π × (radius / cellSize)²
Recommended Configurations
| Search Radius | S2 Level | H3 Resolution | Cell Count |
|---|---|---|---|
| 1-5 km | 16-18 | 8-10 | 10-50 |
| 5-10 km | 14-16 | 7-8 | 20-100 |
| 10-50 km | 12-14 | 5-7 | 50-200 |
| 50-100 km | 10-12 | 4-5 | 100-300 |
Query Explosion Warning
Using too high a precision for a large radius creates thousands of cells, making queries extremely slow or incomplete.
Bad: 10km radius with S2 Level 18 (~400m cells) = ~2,500 cells
Good: 10km radius with S2 Level 14 (~6km cells) = ~11 cells
// ❌ BAD: Too many cells for this radius
[DynamoDbAttribute("location", SpatialIndexType = SpatialIndexType.S2, S2Level = 18)]
// ✅ GOOD: Appropriate precision for 10km queries
[DynamoDbAttribute("location", SpatialIndexType = SpatialIndexType.S2, S2Level = 14)]
MaxCells Limit
The default maxCells limit is 100. When exceeded:
- Only the first 100 cells are queried
- Results are incomplete — no error is thrown
- Monitor
result.TotalCellsQueriedto detect this
var result = await table.SpatialQueryAsync(...);
if (result.TotalCellsQueried >= 100)
{
_logger.LogWarning("Hit maxCells limit — results may be incomplete. " +
"Consider reducing precision or radius.");
}
Multiple Precision Levels
For applications with varying search radii, store data at multiple precisions:
public partial class Store
{
// Low precision for large area queries (50-100km)
[DynamoDbAttribute("location_region", SpatialIndexType = SpatialIndexType.S2, S2Level = 12)]
public GeoLocation LocationRegion => Location;
// Medium precision for city queries (10-50km)
[DynamoDbAttribute("location_city", SpatialIndexType = SpatialIndexType.S2, S2Level = 14)]
public GeoLocation LocationCity => Location;
// High precision for local queries (1-10km)
[DynamoDbAttribute("location_local", SpatialIndexType = SpatialIndexType.S2, S2Level = 16)]
public GeoLocation LocationLocal => Location;
[DynamoDbAttribute("location")]
public GeoLocation Location { get; set; }
}
// Choose attribute based on radius
var attributeName = radiusKm switch
{
<= 10 => "location_local",
<= 50 => "location_city",
_ => "location_region"
};
Cost Optimization
DynamoDB Read Capacity
Each cell query consumes RCUs. Total cost:
Total RCUs = Cell Count × RCUs per Query
Example: 10km radius with S2 Level 16 = 140 cells × 5 RCUs = 700 RCUs per search.
Strategies
- Use lower precision — Fewer cells = fewer queries = lower cost
- Use eventual consistency — Half the RCU cost vs strong consistency
- Implement caching — Cache popular search areas
- Limit search radius — Smaller radius = fewer cells
// Use eventual consistency for spatial queries
var result = await table.SpatialQueryAsync(
center: center,
radiusKilometers: 10,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.WithConsistentRead(false)
.Paginate(pagination),
pageSize: null
);
Monitoring
Track these metrics in production:
var stopwatch = Stopwatch.StartNew();
var result = await table.SpatialQueryAsync(...);
stopwatch.Stop();
_logger.LogInformation(
"Spatial query: Radius={Radius}km, Cells={Cells}, " +
"Scanned={Scanned}, Returned={Returned}, Latency={Latency}ms",
radiusKm,
result.TotalCellsQueried,
result.TotalItemsScanned,
result.Items.Count,
stopwatch.ElapsedMilliseconds);
Alert thresholds:
TotalCellsQueried > 100— Consider reducing precision- Query efficiency (
Items / Scanned) < 50% — Precision may be too low - Latency > 500ms — Review execution mode and precision
Quick Reference
| Scenario | Mode | Precision | Expected Performance |
|---|---|---|---|
| Small area, need all results | Non-paginated | High | ~50ms |
| Large area, need all results | Non-paginated | Low | ~50ms |
| Small area, paginated API | Paginated | High | ~100-200ms first page |
| Large area, paginated API | Paginated | Low | ~100-200ms first page |
When in doubt: Start with S2 Level 16 (non-paginated) for radius < 10km, and S2 Level 14 (paginated) for larger areas. Adjust based on monitoring.
See Also
- Geospatial Overview — Installation and configuration
- GeoHash — Single-query approach (fastest for simple cases)
- S2 Cells — Square cell indexing
- H3 Indexing — Hexagonal cell indexing