Geo-Spatial Indexing
Build location-based features like store locators, delivery radius checks, and real-time tracking with Redis geo-spatial indexes.
Overview
Beanis provides built-in support for geo-spatial data through the GeoPoint type and Redis's GEOADD/GEORADIUS commands. This enables fast proximity searches with sub-millisecond query times.
When to Use Geo-Spatial Indexes
Perfect for: - Store/restaurant locators ("find stores near me") - Delivery radius validation - Real-time vehicle/device tracking - Geo-fencing applications - Nearby user discovery
Performance: - Query time: ~0.2ms average (sub-millisecond) - Scalability: O(log N) - barely increases with dataset size - Insert overhead: ~90-120% slower than non-indexed inserts - Distance calc overhead: ~7% additional time
Quick Start
from beanis import Document, GeoPoint, init_beanis
from beanis.odm.indexes import IndexedField, IndexManager
from typing_extensions import Annotated
from redis.asyncio import Redis
# Define document with geo-indexed location
class Store(Document):
name: str
address: str
location: Annotated[GeoPoint, IndexedField()]
class Settings:
name = "stores"
# Initialize
redis = Redis(decode_responses=True)
await init_beanis(database=redis, document_models=[Store])
# Create store with location
store = Store(
name="Downtown Coffee",
address="123 Main St, San Francisco, CA",
location=GeoPoint(longitude=-122.4194, latitude=37.7749)
)
await store.insert()
# Find stores within 5km
nearby_ids = await IndexManager.find_by_geo_radius(
redis_client=redis,
document_class=Store,
field_name="location",
longitude=-122.4200,
latitude=37.7750,
radius=5,
unit="km"
)
# Get full store documents
nearby_stores = await Store.get_many(nearby_ids)
for store in nearby_stores:
print(f"{store.name} - {store.address}")
GeoPoint Type
Creating GeoPoint
from beanis import GeoPoint
# Standard format
location = GeoPoint(longitude=-122.4194, latitude=37.7749)
# From dict
location = GeoPoint(**{"longitude": -122.4194, "latitude": 37.7749})
# Access values
print(f"Lat: {location.latitude}, Lon: {location.longitude}")
Validation
GeoPoint automatically validates coordinates:
# Valid ranges
# Longitude: -180 to 180
# Latitude: -90 to 90
# This raises ValidationError
try:
invalid = GeoPoint(longitude=200, latitude=37.7)
except ValueError as e:
print(e) # "Longitude must be between -180 and 180"
Radius Queries
Basic Proximity Search
# Find all stores within 10km
nearby = await IndexManager.find_by_geo_radius(
redis_client=redis,
document_class=Store,
field_name="location",
longitude=-122.4194,
latitude=37.7749,
radius=10,
unit="km" # Options: 'm', 'km', 'mi', 'ft'
)
# Returns list of document IDs
print(f"Found {len(nearby)} stores")
Query with Distances
Get results sorted by distance with actual distance values:
# Find stores with distances
results = await IndexManager.find_by_geo_radius_with_distance(
redis_client=redis,
document_class=Store,
field_name="location",
longitude=-122.4194,
latitude=37.7749,
radius=10,
unit="km"
)
# Returns list of (doc_id, distance) tuples
for store_id, distance in results:
store = await Store.get(store_id)
print(f"{store.name}: {distance:.2f} km away")
Supported Distance Units
# Meters
await IndexManager.find_by_geo_radius(..., radius=1000, unit="m")
# Kilometers (default)
await IndexManager.find_by_geo_radius(..., radius=10, unit="km")
# Miles
await IndexManager.find_by_geo_radius(..., radius=5, unit="mi")
# Feet
await IndexManager.find_by_geo_radius(..., radius=5000, unit="ft")
Real-World Example: Food Delivery Service
Let's build a complete food delivery radius checker:
from beanis import Document, GeoPoint
from beanis.odm.indexes import IndexedField, IndexManager
from typing_extensions import Annotated
from typing import List, Optional
from pydantic import BaseModel
# Models
class DeliveryZone(BaseModel):
name: str
max_radius_km: float
class Restaurant(Document):
name: str
cuisine: str
location: Annotated[GeoPoint, IndexedField()]
delivery_zones: List[DeliveryZone]
is_open: bool
class Settings:
name = "restaurants"
class DeliveryAddress(BaseModel):
location: GeoPoint
formatted_address: str
# Business logic
class DeliveryService:
def __init__(self, redis_client):
self.redis = redis_client
async def find_available_restaurants(
self,
address: DeliveryAddress,
max_distance_km: float = 10
) -> List[tuple[Restaurant, float]]:
"""
Find restaurants that deliver to given address
Returns list of (restaurant, distance) sorted by distance
"""
# Find all restaurants within max distance
results = await IndexManager.find_by_geo_radius_with_distance(
redis_client=self.redis,
document_class=Restaurant,
field_name="location",
longitude=address.location.longitude,
latitude=address.location.latitude,
radius=max_distance_km,
unit="km"
)
# Fetch restaurant details and filter by delivery zones
available = []
for restaurant_id, distance in results:
restaurant = await Restaurant.get(restaurant_id)
# Skip if closed
if not restaurant.is_open:
continue
# Check if address is in any delivery zone
for zone in restaurant.delivery_zones:
if distance <= zone.max_radius_km:
available.append((restaurant, distance))
break
# Sort by distance
available.sort(key=lambda x: x[1])
return available
async def check_delivery_available(
self,
restaurant_id: str,
address: DeliveryAddress
) -> tuple[bool, Optional[float]]:
"""
Check if restaurant delivers to address
Returns (is_available, distance_km)
"""
restaurant = await Restaurant.get(restaurant_id)
if not restaurant or not restaurant.is_open:
return (False, None)
# Calculate distance
results = await IndexManager.find_by_geo_radius_with_distance(
redis_client=self.redis,
document_class=Restaurant,
field_name="location",
longitude=address.location.longitude,
latitude=address.location.latitude,
radius=max(zone.max_radius_km for zone in restaurant.delivery_zones),
unit="km"
)
# Find this restaurant in results
for doc_id, distance in results:
if doc_id == restaurant_id:
# Check if in any delivery zone
for zone in restaurant.delivery_zones:
if distance <= zone.max_radius_km:
return (True, distance)
return (False, distance)
return (False, None)
async def get_delivery_estimate(
self,
restaurant: Restaurant,
distance_km: float
) -> dict:
"""
Calculate delivery time and fee based on distance
"""
# Base delivery time: 20 minutes + 3 min per km
delivery_time_min = 20 + (distance_km * 3)
# Delivery fee: $2.99 base + $0.50 per km
delivery_fee = 2.99 + (distance_km * 0.50)
return {
"restaurant_name": restaurant.name,
"distance_km": round(distance_km, 2),
"estimated_delivery_min": round(delivery_time_min),
"delivery_fee": round(delivery_fee, 2)
}
# Usage example
async def main():
from redis.asyncio import Redis
from beanis import init_beanis
redis = Redis(decode_responses=True)
await init_beanis(database=redis, document_models=[Restaurant])
# Create sample restaurants
restaurants = [
Restaurant(
name="Pizza Palace",
cuisine="Italian",
location=GeoPoint(longitude=-122.4194, latitude=37.7749),
delivery_zones=[
DeliveryZone(name="Downtown", max_radius_km=5),
DeliveryZone(name="Extended", max_radius_km=10)
],
is_open=True
),
Restaurant(
name="Sushi Express",
cuisine="Japanese",
location=GeoPoint(longitude=-122.4100, latitude=37.7850),
delivery_zones=[
DeliveryZone(name="Local", max_radius_km=3)
],
is_open=True
),
Restaurant(
name="Burger Joint",
cuisine="American",
location=GeoPoint(longitude=-122.4300, latitude=37.7650),
delivery_zones=[
DeliveryZone(name="Wide", max_radius_km=15)
],
is_open=True
)
]
for restaurant in restaurants:
await restaurant.insert()
# User's delivery address
user_address = DeliveryAddress(
location=GeoPoint(longitude=-122.4150, latitude=37.7800),
formatted_address="456 Market St, San Francisco, CA"
)
# Find available restaurants
service = DeliveryService(redis)
available = await service.find_available_restaurants(user_address)
print(f"Restaurants delivering to {user_address.formatted_address}:\n")
for restaurant, distance in available:
estimate = await service.get_delivery_estimate(restaurant, distance)
print(f"{estimate['restaurant_name']} ({restaurant.cuisine})")
print(f" Distance: {estimate['distance_km']} km")
print(f" Delivery time: ~{estimate['estimated_delivery_min']} min")
print(f" Delivery fee: ${estimate['delivery_fee']}")
print()
await redis.aclose()
# Run
import asyncio
asyncio.run(main())
Output:
Restaurants delivering to 456 Market St, San Francisco, CA:
Pizza Palace (Italian)
Distance: 0.73 km
Delivery time: ~22 min
Delivery fee: $3.36
Sushi Express (Japanese)
Distance: 1.12 km
Delivery time: ~23 min
Delivery fee: $3.55
Burger Joint (American)
Distance: 2.21 km
Delivery time: ~27 min
Delivery fee: $4.10
More Use Cases
Store Locator
class Store(Document):
name: str
address: str
phone: str
location: Annotated[GeoPoint, IndexedField()]
store_hours: dict
class Settings:
name = "stores"
async def find_nearest_store(user_lat: float, user_lon: float, limit: int = 5):
"""Find nearest stores to user"""
results = await IndexManager.find_by_geo_radius_with_distance(
redis_client=redis,
document_class=Store,
field_name="location",
longitude=user_lon,
latitude=user_lat,
radius=50, # Within 50km
unit="km"
)
# Get top N nearest
nearest = results[:limit]
stores_with_distance = []
for store_id, distance in nearest:
store = await Store.get(store_id)
stores_with_distance.append({
"store": store,
"distance_km": distance,
"distance_mi": distance * 0.621371 # Convert to miles
})
return stores_with_distance
Real-Time Vehicle Tracking
from datetime import datetime
class Vehicle(Document):
vehicle_id: str
driver_name: str
location: Annotated[GeoPoint, IndexedField()]
last_updated: datetime
is_available: bool
class Settings:
name = "vehicles"
async def find_nearest_available_driver(
pickup_location: GeoPoint,
max_distance_km: float = 5
) -> Optional[tuple[Vehicle, float]]:
"""Find nearest available driver for ride-hailing"""
results = await IndexManager.find_by_geo_radius_with_distance(
redis_client=redis,
document_class=Vehicle,
field_name="location",
longitude=pickup_location.longitude,
latitude=pickup_location.latitude,
radius=max_distance_km,
unit="km"
)
# Find first available driver
for vehicle_id, distance in results:
vehicle = await Vehicle.get(vehicle_id)
if vehicle.is_available:
return (vehicle, distance)
return None
async def update_vehicle_location(vehicle_id: str, new_location: GeoPoint):
"""Update driver location in real-time"""
vehicle = await Vehicle.get(vehicle_id)
vehicle.location = new_location
vehicle.last_updated = datetime.utcnow()
await vehicle.save() # Geo index automatically updated
Geo-Fencing / Zone Detection
class GeofenceZone(Document):
name: str
center: Annotated[GeoPoint, IndexedField()]
radius_km: float
zone_type: str # "delivery", "restricted", "premium"
class Settings:
name = "geofence_zones"
async def check_if_in_zone(
location: GeoPoint,
zone_type: Optional[str] = None
) -> List[GeofenceZone]:
"""Check if location is within any geofence zones"""
# Query with large radius to get all potential zones
results = await IndexManager.find_by_geo_radius_with_distance(
redis_client=redis,
document_class=GeofenceZone,
field_name="center",
longitude=location.longitude,
latitude=location.latitude,
radius=100, # Max zone size
unit="km"
)
zones_containing_location = []
for zone_id, distance in results:
zone = await GeofenceZone.get(zone_id)
# Check if location is within zone's radius
if distance <= zone.radius_km:
if zone_type is None or zone.zone_type == zone_type:
zones_containing_location.append(zone)
return zones_containing_location
Performance Tips
1. Choose Appropriate Radius
Smaller radius = faster queries:
# Fast: ~0.19ms
nearby = await find_by_geo_radius(..., radius=1, unit="km")
# Still fast: ~0.23ms
nearby = await find_by_geo_radius(..., radius=10, unit="km")
# Slower: ~0.27ms (more results to process)
nearby = await find_by_geo_radius(..., radius=50, unit="km")
2. Batch Queries When Possible
# ❌ Slow: Multiple round-trips
for user in users:
nearby = await find_by_geo_radius(user.location, ...)
# ✅ Better: Batch processing
user_locations = [user.location for user in users]
# Process in batches or use async gather
3. Cache Frequent Queries
from functools import lru_cache
@lru_cache(maxsize=1000)
def get_cached_nearby_stores(lat: float, lon: float, radius: int):
# Round coordinates to reduce cache misses
lat_rounded = round(lat, 3)
lon_rounded = round(lon, 3)
# ... query logic
4. Use Distance Only When Needed
# If you only need IDs (7% faster)
ids = await find_by_geo_radius(...)
# If you need distances for sorting/display
results = await find_by_geo_radius_with_distance(...)
Limitations
Cannot Combine with Other Indexes
Geo queries are separate from other index queries:
# ❌ Cannot do this in one query
# "Find Italian restaurants within 5km"
# ✅ Do this instead:
# 1. Find by geo proximity
nearby_ids = await find_by_geo_radius(..., radius=5)
# 2. Filter by other criteria
nearby_restaurants = await Restaurant.get_many(nearby_ids)
italian = [r for r in nearby_restaurants if r.cuisine == "Italian"]
One GeoPoint Per Document
Each document can only have one geo-indexed location:
# ❌ Multiple geo indexes not supported
class Business(Document):
main_location: Annotated[GeoPoint, IndexedField()]
warehouse_location: Annotated[GeoPoint, IndexedField()] # Won't work well
# ✅ Use separate documents
class BusinessLocation(Document):
business_id: str
location_type: str # "main" or "warehouse"
location: Annotated[GeoPoint, IndexedField()]
Benchmark Results
Based on our comprehensive benchmarks:
| Metric | Value | Notes |
|---|---|---|
| Query time (avg) | 0.2ms | Sub-millisecond |
| Query time (P95) | 0.21ms | Very consistent |
| Insert overhead | 90-120% | ~2x slower with geo index |
| Distance overhead | 7% | Minimal cost |
| Scalability | O(log N) | Time barely increases |
Dataset tested: 10,000 stores, 25km radius queries
Comparison with MongoDB
| Feature | MongoDB (2dsphere) | Beanis (Redis GEO) |
|---|---|---|
| Query time | 5-20ms | 0.2ms (10-100x faster) |
| Index size | Larger | Smaller (sorted set) |
| Complex queries | ✅ $near + filters | ⚠️ Filter after query |
| Distance units | ✅ All units | ✅ m, km, mi, ft |
| Polygon queries | ✅ Supported | ❌ Radius only |
| Max distance | Unlimited | Practical: ~500km |
Best Practices
- Always validate coordinates - GeoPoint does this automatically
- Use appropriate radius - Start small, increase if needed
- Cache frequent locations - User's home, work addresses
- Update locations efficiently - Geo index updates automatically on save
- Consider data distribution - Works best with evenly distributed points
- Monitor query times - Should stay under 1ms for most use cases
Troubleshooting
Queries Return No Results
# Check if documents have geo indexes
members = await redis.zrange("idx:Store:location", 0, -1)
print(f"Indexed stores: {len(members)}")
# Verify location is saved correctly
store = await Store.get(store_id)
print(f"Location: {store.location}")
# Check radius is reasonable
# 1 degree ≈ 111km, so 500km = ~4.5 degrees
Slow Insert Performance
# If geo indexing is not needed for all documents:
class Store(Document):
location: GeoPoint # No index
# Manually index only important stores
await IndexManager.add_to_index(
redis_client, Store, store.id, "location", store.location, "geo"
)
Next Steps
- Indexes Overview - Learn about other index types
- Custom Encoders - Extend GeoPoint with custom fields
- API Reference - Full IndexManager documentation
Further Reading
- Redis GEOADD
- Redis GEORADIUS
- Haversine Formula - Distance calculation method used by Redis