Skip to main content

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

LevelCell SizeUse Case
10~100 kmRegional searches
12~25 kmCity-wide searches
14~6 kmDistrict searches
16~1.5 kmDefault — Neighborhood searches
18~400 mStreet-level searches
20~100 mBuilding-level searches
22~25 mPrecise 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 RadiusRecommended LevelApproximate Cell Count
1-5 km16-1810-50
5-10 km14-1620-100
10-50 km12-1450-200
50-100 km10-12100-300
Query Explosion

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