Spatial Queries Guide
SpatialFlow uses GeoDjango with PostGIS for all spatial operations. Every geofence check, proximity search, and distance calculation runs as a PostGIS query against indexed geometry columns. This guide covers the core spatial query patterns available through the SpatialFlow API and the GeoDjango ORM they are built on.
Coordinate Order
SpatialFlow follows the GeoJSON standard (RFC 7946): coordinates are always [longitude, latitude]. This applies to all API requests, responses, and PostGIS functions.
[-122.4194, 37.7749] // [longitude, latitude] — San Francisco
Many mapping libraries (Google Maps, Leaflet) use [lat, lng] order. Make sure to swap the values when converting between formats.
Containment Queries (ST_Contains)
Containment queries answer: "Is this point inside a geofence?" SpatialFlow uses ST_Contains for polygon geofences and ST_DWithin for circle geofences (stored as a center point with a radius).
GeoDjango ORM
Find all polygon geofences that contain a given point:
from django.contrib.gis.geos import Point
# Note: Point takes (longitude, latitude) — x, y order
point = Point(-122.4194, 37.7749, srid=4326) # [longitude, latitude]
# Returns all active geofences whose polygon contains the point
geofences = Geofence.objects.filter(
geometry__contains=point
)
For circle geofences (stored as a Point geometry with radius_meters), SpatialFlow uses ST_DWithin with a geography cast for accurate meter-based distance:
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
point = Point(-122.4194, 37.7749, srid=4326) # [longitude, latitude]
# Circle geofences: find where the point is within radius_meters of the center
circle_geofences = Geofence.objects.filter(
geometry__distance_lte=(point, D(m=F("radius_meters")))
)
REST API
Test whether a point falls inside any of your workspace's geofences:
curl -X POST https://api.spatialflow.io/api/v1/geofences/test-point \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"geometry": {
"type": "Point",
"coordinates": [-122.4194, 37.7749]
}
}'
Response:
{
"results": [
{
"id": "a1b2c3d4-...",
"name": "Downtown Office",
"contains": true,
"distance_meters": 0.0
},
{
"id": "e5f6g7h8-...",
"name": "Nearby Warehouse",
"contains": false,
"distance_meters": 342.7
}
]
}
The endpoint tests the point against all active geofences in your workspace in a single SQL query, returning containment status and distance for each.
Proximity Queries (ST_DWithin / ST_Distance)
Proximity queries answer: "Which geofences are near this point?" SpatialFlow uses ST_DWithin to filter by maximum distance and ST_Distance to compute exact distances.
GeoDjango ORM
Find geofences within a given radius, ordered by distance:
from django.contrib.gis.geos import Point
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.measure import D
point = Point(-122.4194, 37.7749, srid=4326) # [longitude, latitude]
nearby = (
Geofence.objects
.filter(geometry__distance_lte=(point, D(km=5)))
.annotate(distance=Distance("geometry", point))
.order_by("distance")
)
for geofence in nearby:
print(f"{geofence.name}: {geofence.distance.m:.0f} meters away")
REST API
Query geofences near a location:
curl -X GET "https://api.spatialflow.io/api/v1/geofences?near_lat=37.7749&near_lng=-122.4194&radius_km=5" \
-H "Authorization: Bearer YOUR_API_KEY"
When you need distances in meters, cast to geography:
ST_Distance(geometry::geography, point::geography) -- returns meters
Without the cast, ST_Distance on geometry returns degrees (which are not useful for real-world measurements). SpatialFlow's proximity queries always use geography casting internally.
Intersection Queries (Enter/Exit Detection)
SpatialFlow detects geofence enter and exit events by comparing which geofences contain the new location versus the previous location. This is a set-based approach -- a single SQL pass per location update.
How It Works
# Conceptual flow inside Device.update_location()
# 1. Query all geofences containing the NEW point
current_geofences = set(
Geofence.objects.filter(
workspace_id=device.workspace_id,
is_active=True,
geometry__contains=new_point,
).values_list("id", flat=True)
)
# 2. Query all geofences that contained the PREVIOUS point
previous_geofences = set(
Geofence.objects.filter(
workspace_id=device.workspace_id,
is_active=True,
geometry__contains=previous_point,
).values_list("id", flat=True)
)
# 3. Compute entries and exits with set operations
entries = current_geofences - previous_geofences # newly entered
exits = previous_geofences - current_geofences # just left
# 4. Create GeofenceEvent records for each transition
for geofence_id in entries:
GeofenceEvent.objects.create(
device=device, geofence_id=geofence_id, event_type="entry", ...
)
for geofence_id in exits:
GeofenceEvent.objects.create(
device=device, geofence_id=geofence_id, event_type="exit", ...
)
This pattern handles any number of overlapping geofences efficiently. A device can enter multiple geofences and exit others in a single location update.
Polygon and Circle Support
The actual SQL query handles both geometry types in one pass:
SELECT g.id, g.name
FROM geofences g
WHERE g.workspace_id = %s
AND g.is_active = true
AND (
-- Polygon/MultiPolygon geofences: standard containment
(g.radius_meters IS NULL
AND ST_Contains(g.geometry, ST_SetSRID(ST_MakePoint(%s, %s), 4326)))
OR
-- Circle geofences: distance-based containment
(g.radius_meters IS NOT NULL
AND ST_DWithin(
g.geometry::geography,
ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography,
g.radius_meters
))
)
Distance Calculations
SpatialFlow uses PostGIS geography types for accurate geodesic distance calculations on the WGS84 ellipsoid.
Point-to-Point Distance
-- Distance between two points in meters
SELECT ST_Distance(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography,
ST_SetSRID(ST_MakePoint(-122.4089, 37.7837), 4326)::geography
) AS distance_meters;
-- Returns: ~1234.5 (meters)
Track Distance (LineString Length)
SpatialFlow calculates the total distance a device traveled during a session by building a line from ordered location points and measuring its geodesic length:
# From Device._calculate_geodesic_distance()
# Uses ST_MakeLine to connect points, then ST_Length on geography for meters
with connection.cursor() as cursor:
cursor.execute("""
SELECT ST_Length(
ST_MakeLine(location ORDER BY timestamp)::geography
)
FROM device_locations
WHERE id = ANY(%s)
""", [location_ids])
distance_meters = cursor.fetchone()[0]
The ::geography cast ensures ST_Length returns meters using WGS84 ellipsoid calculations instead of planar degrees.
Performance Tips
Spatial queries are fast when backed by proper indexes. SpatialFlow automatically creates GIST indexes on all geometry columns (Geofence.geometry, Device.last_location, DeviceLocation.location). Every spatial query also filters by workspace_id (B-tree index) first, narrowing the dataset before the spatial operation runs.
For detailed coverage of index types, compound query strategies, and optimization patterns, see the Spatial Indexing guide.
Next Steps
- Spatial Indexing -- GIST indexes, query optimization, and common pitfalls
- Vehicle Tracking -- End-to-end tracking with geofence workflows
- Geofences -- Geofence types, properties, and management