When your ride-hailing platform needs to find drivers within two kilometers, or your marketplace must list nearby restaurants, a naive database scan becomes your bottleneck. Geospatial queries in practice: best practices explained gives you the tools to handle location-based lookups at scale, whether you're serving hundreds or millions of coordinate points.
Why geospatial queries matter
Location-based features drive user engagement across ride-hailing, delivery, travel, and local commerce. Your backend might receive thousands of concurrent requests asking "what's near me?" A straightforward approach—fetching all records and computing distances in application code—crumbles under load. You need a database layer that understands geography natively.
The three approaches we'll cover today form a pyramid: geohashes provide a lightweight prefix-based filter, PostGIS offers precise distance calculations with spatial indexing, and Redis GEO commands deliver sub-millisecond lookups for frequently-queried hotspots. Most production systems use a combination.
Geohashes turn locations into searchable prefixes
A geohash encodes latitude and longitude into a string of characters. Each character represents one additional level of precision. This seemingly simple trick lets you filter enormous datasets by substring matching—something every database optimizes ruthlessly.
The principle is elegant: the world is recursively divided into rectangles. Latitude and longitude bits interleave, creating a Z-order curve that preserves spatial locality. A geohash of length 5 represents roughly a 4.9 km × 4.9 km area; length 7 narrows to 153 m × 153 m; length 9 gives you ±2.4 m precision.
Here's how you might index geohashes in PostgreSQL:
-- Store venues with geohash
CREATE TABLE venues (
id BIGINT PRIMARY KEY,
name VARCHAR,
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
geohash_prefix VARCHAR(7)
);
CREATE INDEX idx_geohash ON venues(geohash_prefix);
-- Generate geohash in your application (use a library like geolib)
// Pseudocode: encode(37.7749, -122.4194) => "9q8yy"
const geohash = encodeGeohash(venue.latitude, venue.longitude, 7);
-- Query: find all venues in the same geohash block
SELECT * FROM venues
WHERE geohash_prefix = '9q8yy';
Geohashes excel at coarse filtering. The trade-off is well worth understanding: a geohash cell has fuzzy boundaries. Two points at the border of adjacent cells may be very close despite having different geohashes. That's why geohashes work best as a first filter, not as the final answer.
PostGIS handles distance queries in PostgreSQL
When precision matters, PostGIS is the production standard. This extension adds proper spatial types, operators, and indexing (GiST, BRIN) to PostgreSQL. It understands geography—curvature of the Earth, great-circle distances, polygon containment—all at the SQL level.
Setting up PostGIS:
-- Enable the extension
CREATE EXTENSION IF NOT EXISTS postgis;
-- Redesign your table with proper geographic types
CREATE TABLE drivers (
id BIGINT PRIMARY KEY,
name VARCHAR,
location GEOGRAPHY(POINT, 4326) -- 4326 is WGS84
);
-- Build spatial index for fast searches
CREATE INDEX idx_drivers_location ON drivers USING GIST(location);
-- Insert data
INSERT INTO drivers (id, name, location) VALUES
(1, 'Alice', 'POINT(37.7749 -122.4194)');
-- Query: find drivers within 2 km of a pickup point
SELECT id, name,
ST_Distance(location, 'POINT(37.7744 -122.4189)'::GEOGRAPHY) AS distance_m
FROM drivers
WHERE ST_DWithin(location, 'POINT(37.7744 -122.4189)'::GEOGRAPHY, 2000)
ORDER BY distance_m ASC
LIMIT 10;
The ST_DWithin function uses the spatial index to narrow the result set before computing actual distances. This is critical: it eliminates most candidates without expensive calculations. On a table of millions of drivers, this difference means seconds versus microseconds.
PostGIS also supports complex queries. Need drivers within 2 km who have maintained a 4.8-star rating in the past week? Combine spatial and business logic filters:
SELECT id, name, ST_Distance(location, point) AS distance_m
FROM drivers
WHERE ST_DWithin(location, point, 2000)
AND rating >= 4.8
AND last_rated_at > NOW() - INTERVAL '7 days'
ORDER BY distance_m ASC, rating DESC
LIMIT 10;
One caveat: PostGIS calculations assume the Earth is a sphere (or ellipsoid if using true geography). For fine-grained local coordinates in a warehouse or campus, you might use geometry types instead, which assume a flat plane and run slightly faster.
Redis GEO commands for fast in-memory lookups
When you need sub-millisecond response times for hot data, move active locations into Redis. The Redis GEO commands (built on sorted sets) let you store and query millions of points with incredible speed.
Redis GEO works by encoding latitude/longitude pairs as geohashes stored in a sorted set. The score is the geohash number; members are your identifiers. This design enables range queries by score—exactly what you need for proximity searches.
Usage pattern:
// Add active drivers to Redis
redis.geoadd('active_drivers',
-122.4194, 37.7749, 'driver:1',
-122.4200, 37.7750, 'driver:2',
-122.4180, 37.7755, 'driver:3'
);
// Find drivers within 2 km of pickup point
const nearby = await redis.georadius(
'active_drivers',
-122.4189, // pickup longitude
37.7744, // pickup latitude
2, // radius
'km', // unit
'WITHCOORD', // include coordinates
'WITHDIST', // include distance
'COUNT', 10 // limit results
);
// Result: [
// ['driver:1', '0.0726', [-122.4194, 37.7749]],
// ['driver:2', '0.0934', [-122.4200, 37.7750]]
// ]
Why Redis GEO for active drivers? Because drivers update their position frequently—sometimes multiple times per second. Syncing every update to PostgreSQL creates I/O bottlenecks. Instead, keep the current snapshot in Redis, write historical/analytical data to PostgreSQL asynchronously. This gives you real-time speed with eventual consistency.
A practical setup: when a driver comes online, add them to Redis GEO. Expire the entry after 5 minutes of inactivity (Redis handles TTLs automatically). For requests, query Redis first; if no candidates are found, fall back to PostgreSQL for a wider search or historical data.
Filter by bounding box first, then exact distance
Whether using PostGIS or Redis, the sequence matters: coarse filter before fine filter. A bounding box (also called an MBR—minimum bounding rectangle) is faster to compute than distance.
Here's the two-stage approach:
- Bounding box filter: Find all points within a square around the query point.
- Distance filter: Calculate exact distances and order results.
In PostGIS, this happens implicitly when you use ST_DWithin with a spatial index. But you can make it explicit if you're writing custom logic:
const queryPoint = { lat: 37.7744, lon: -122.4189 };
const radiusKm = 2;
// Approximate degrees per km (varies by latitude, but ~0.009 is close)
const degreesPerKm = 0.009;
const delta = radiusKm * degreesPerKm;
// Stage 1: Bounding box (very fast, uses index)
const results = await db.query(`
SELECT id, name, latitude, longitude
FROM venues
WHERE latitude BETWEEN $1 AND $2
AND longitude BETWEEN $3 AND $4
`, [
queryPoint.lat - delta,
queryPoint.lat + delta,
queryPoint.lon - delta,
queryPoint.lon + delta
]);
// Stage 2: Exact distance (compute in-memory)
const sorted = results.map(venue => ({
...venue,
distanceKm: haversine(queryPoint, venue)
}))
.filter(v => v.distanceKm <= radiusKm)
.sort((a, b) => a.distanceKm - b.distanceKm);
return sorted.slice(0, 10);
The bounding box eliminates most candidates instantly. Only the handful of points near the boundary require the more expensive haversine calculation. For tables with millions of points, this two-stage approach is often faster than relying on a spatial index alone, especially if your query radius is small.
Common mistakes
- Forgetting to index: Without a GiST index on PostGIS columns or proper geohash partitioning, queries scan the entire table. Always index spatial columns. Test query plans with
EXPLAIN. - Mixing coordinate systems: If your data is in WGS84 (4326) but you query in Web Mercator (3857), results are nonsensical. Be explicit about SRID (Spatial Reference ID) in PostGIS.
- Ignoring geohash boundaries: Geohashes alone are not precise. Two nearby points may have different geohashes. Use them for filtering, not as ground truth.
- Computing distance in application code at scale: Fetching all records and calculating distances in Python or Node.js is slow. Let the database do it. If you must compute client-side, fetch a much smaller candidate set first.
- Stale Redis data: If drivers rarely expire from Redis, your app may route requests to offline drivers. Use reasonable TTLs and verify availability before assignment.
- Not profiling with realistic data: Geospatial performance depends on data distribution. A uniformly-distributed 1M-point dataset behaves differently from one clustered in a city. Profile with production-like data volumes and geography.
Putting it together: a production pattern
A mature ride-hailing or delivery backend typically combines all three techniques:
- Driver update arrives (location change).
- Write to Redis GEO immediately (sub-millisecond).
- Queue an async job to write to PostgreSQL and update geohash partitions (eventual consistency).
- When a user requests a ride, query Redis GEO first with 2 km radius.
- If results are sparse, expand the radius in Redis or query PostgreSQL for drivers from the last 5 minutes of updates.
This hybrid approach balances real-time performance (Redis) with scalability and analytics (PostgreSQL). Geohashes can partition your PostgreSQL table, keeping each partition manageable and index-friendly.
Key takeaways
- Geohashes are lightweight prefixes that enable database-level filtering; they're fast but imprecise at cell boundaries.
- PostGIS with spatial indexes (GiST) is the standard for precise, scalable geospatial queries in production PostgreSQL deployments.
- Redis GEO commands deliver sub-millisecond lookups for hot, frequently-updated datasets like active driver locations.
- Always filter by bounding box or coarse criteria first, then refine with exact distance calculations; this two-stage approach is dramatically faster at scale.
- Combine tools: use Redis for real-time active data, PostgreSQL for durability and analytics, and geohashes for partitioning and backup filtering.
- Index every spatial column and test query plans before production; unindexed geospatial queries are among the slowest queries you'll encounter.
- Match the tool to the use case: geohashes for coarse bucketing, PostGIS for correctness, Redis for speed.






Comments
Loading comments…