S2 Cells
S2 is Google's spherical geometry system that projects the Earth onto a cube, providing better area uniformity than GeoHash, especially near the poles.
How S2 Works
S2 divides the Earth's surface by projecting it onto the six faces of a cube, then recursively subdividing each face into four quadrants. Each subdivision increases the level by 1, producing cells that are more uniform in area than GeoHash rectangles.
Entity Definition
using Oproto.FluentDynamoDb.Attributes;
using Oproto.FluentDynamoDb.Geospatial;
[DynamoDbTable("stores")]
public partial class Store
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string StoreId { get; set; } = string.Empty;
[DynamoDbAttribute("location", SpatialIndexType = SpatialIndexType.S2, S2Level = 16)]
public GeoLocation Location { get; set; }
[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;
}
S2 Levels
| Level | Cell Size | Use Case |
|---|---|---|
| 10 | ~100 km | Regional searches |
| 12 | ~25 km | City-wide searches |
| 14 | ~6 km | District searches |
| 16 | ~1.5 km | Default — Neighborhood searches |
| 18 | ~400 m | Street-level searches |
| 20 | ~100 m | Building-level searches |
| 22 | ~25 m | Precise location tracking |
Querying with SpatialQueryAsync
S2 queries use the SpatialQueryAsync API which computes cell coverings and executes queries:
Non-Paginated (Fastest)
All cell queries execute in parallel:
var center = new GeoLocation(37.7749, -122.4194);
var result = await storeTable.SpatialQueryAsync(
spatialAttributeName: "location",
center: center,
radiusKilometers: 5,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: null // All cells queried in parallel
);
// Results are automatically sorted by distance
foreach (var store in result.Items)
{
var distance = store.Location.DistanceToKilometers(center);
Console.WriteLine($"{store.Name}: {distance:F2}km away");
}
Paginated (Memory Efficient)
Cells are queried sequentially in spiral order (closest first):
var result = await storeTable.SpatialQueryAsync(
spatialAttributeName: "location",
center: center,
radiusKilometers: 10,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: 50
);
// Get next page
if (result.ContinuationToken != null)
{
var nextPage = await storeTable.SpatialQueryAsync(
spatialAttributeName: "location",
center: center,
radiusKilometers: 10,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: 50,
continuationToken: result.ContinuationToken
);
}
Working with S2 Cells Directly
using Oproto.FluentDynamoDb.Geospatial.S2;
var location = new GeoLocation(37.7749, -122.4194);
// Convert to S2 cell
var cell = location.ToS2Cell(level: 16);
Console.WriteLine($"S2 Token: {cell.Token}");
Console.WriteLine($"Level: {cell.Level}");
// Get neighboring cells (8 neighbors)
var neighbors = cell.GetNeighbors();
// Get parent cell (lower precision)
var parent = cell.GetParent(); // Level 15
// Get child cells (4 children)
var children = cell.GetChildren(); // Level 17
When to Use S2
Best for:
- Global coverage (uniform cells everywhere)
- Polar regions (handles longitude convergence correctly)
- Hierarchical queries (parent/child relationships)
- Applications requiring consistent cell sizes
Compared to GeoHash:
- Better area uniformity (especially near poles)
- Multiple queries instead of single BETWEEN
- Same latency in non-paginated mode (parallel execution)
Edge Case Handling
The library automatically handles:
- International Date Line — Queries crossing ±180° are split into two bounding boxes
- Polar Regions — Longitude expansion when search area includes a pole
- Cell Deduplication — Overlapping cells from split queries are deduplicated
// Query near the North Pole — handled automatically
var center = new GeoLocation(89, 0);
var result = await storeTable.SpatialQueryAsync(
spatialAttributeName: "location",
center: center,
radiusKilometers: 200,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: 50
);
Precision Selection
Match S2 level to your typical search radius. Cell size should be 20-50% of the search radius:
| Search Radius | Recommended Level | Approximate Cell Count |
|---|---|---|
| 1-5 km | 16-18 | 10-50 |
| 5-10 km | 14-16 | 20-100 |
| 10-50 km | 12-14 | 50-200 |
| 50-100 km | 10-12 | 100-300 |
Using too high a level for a large radius creates thousands of cells. For example, 10km radius with Level 18 (~400m cells) generates ~2,500 cells. Use the Query Optimization guide to choose correctly.
See Also
- Geospatial Overview — Installation and configuration
- GeoHash — Simpler single-query approach
- H3 Indexing — Hexagonal grid alternative
- Query Optimization — Performance tuning and precision selection