Redis Geo: Finding Nearest Locations with Spring Boot

1. Overview

The “find nearby stores” feature appears in most food delivery, ride-sharing, and coffee chain map apps. You could write the haversine formula yourself and scan through all locations, but this approach is slow with large datasets.

Redis provides a built-in set of Geo commands to solve this problem with latency of just a few milliseconds. This article guides you through storing coordinates in Redis, finding the nearest locations using GEOSEARCH, calculating distances between two points, and writing tests for these operations in Spring Boot 3.x.

2. How Redis Geo Works

Redis doesn’t have a dedicated Geo data type. Each coordinate is encoded into a 52-bit geohash, then stored as a score in a sorted set. Nearby points typically have nearby geohashes. Because of this, Redis only needs to scan a few score ranges instead of the entire dataset.

You can try it quickly with redis-cli:

GEOADD coffee:hanoi 105.8547 21.0334 cafe-giang
GEOADD coffee:hanoi 105.8541 21.0251 cong-trang-tien
GEOADD coffee:hanoi 105.8128 21.0318 highlands-lotte

GEOSEARCH coffee:hanoi FROMLONLAT 105.8522 21.0287 BYRADIUS 2 km ASC WITHDIST

The GEOADD command takes longitude first, latitude second. GEOSEARCH finds members within a 2 km radius around Hoan Kiem Lake, sorted from nearest to farthest (ASC), and returns distances (WITHDIST):

1) 1) "cong-trang-tien"
   2) "0.4465"
2) 1) "cafe-giang"
   2) "0.5845"

highlands-lotte is about 4 km away, so it doesn’t appear. The numbers on your machine may differ in the decimal places because Redis uses geohash and a spherical Earth model.

Since it’s fundamentally a sorted set, you can still use ZRANGE, ZCARD, and ZREM on the same key.

Note: GEORADIUS and GEORADIUSBYMEMBER have been marked deprecated since Redis 6.2. For new code, you should use GEOSEARCH.

3. Setting Up Spring Data Redis

Add the Redis starter to pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Configure the connection in application.properties:

spring.data.redis.host=localhost
spring.data.redis.port=6379

Spring Boot automatically creates a StringRedisTemplate bean. This is the right choice for Geo, since members are usually string IDs. You access Geo commands through opsForGeo(), which returns GeoOperations<String, String>.

4. Storing Locations

Create a service that bundles all Geo operations:

@Service
public class StoreLocatorService {

    static final String KEY = "coffee:hanoi";

    private final GeoOperations<String, String> geoOps;

    public StoreLocatorService(StringRedisTemplate redisTemplate) {
        this.geoOps = redisTemplate.opsForGeo();
    }

    public void addStore(String storeId, double longitude, double latitude) {
        geoOps.add(KEY, new Point(longitude, latitude), storeId);
    }

    public void removeStore(String storeId) {
        geoOps.remove(KEY, storeId);
    }
}

What each part does:

  • Point belongs to package org.springframework.data.geo. The constructor takes (x, y), which is (longitude, latitude).
  • geoOps.add(...) is equivalent to GEOADD. If storeId already exists, the old coordinates are overwritten. You won’t end up with duplicate records.
  • geoOps.remove(...) actually calls ZREM, since Geo is a sorted set.

When you need to load many points at once, you pass a Map<String, Point> to send them all in a single command:

public void addStores(Map<String, Point> stores) {
    geoOps.add(KEY, stores);
}

This saves network round-trips compared to calling addStore in a loop.

5. Finding the Nearest Location

5.1. Finding by User Coordinates

The most common scenario is when the app sends the user’s GPS location. You need to return the N nearest stores within a given radius. First, declare a record for the result:

public record NearbyStore(String storeId, double distanceKm, double longitude, double latitude) {
}

Then add a search method to StoreLocatorService:

public List<NearbyStore> findNearest(double longitude, double latitude,
                                     double radiusKm, int limit) {
    var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance()
            .includeCoordinates()
            .sortAscending()
            .limit(limit);

    GeoResults<RedisGeoCommands.GeoLocation<String>> results = geoOps.search(
            KEY,
            GeoReference.fromCoordinate(longitude, latitude),
            new Distance(radiusKm, Metrics.KILOMETERS),
            args);

    if (results == null) {
        return List.of();
    }

    return results.getContent().stream()
            .map(r -> new NearbyStore(
                    r.getContent().getName(),
                    r.getDistance().getValue(),
                    r.getContent().getPoint().getX(),
                    r.getContent().getPoint().getY()))
            .toList();
}

What each part does:

  • GeoReference.fromCoordinate(...) corresponds to FROMLONLAT, which is the reference point for measuring distance.
  • new Distance(radiusKm, Metrics.KILOMETERS) corresponds to BYRADIUS 2 km. The unit you choose here is also the unit for the returned distance.
  • includeDistance() and includeCoordinates() add WITHDIST and WITHCOORD. Without includeCoordinates(), getPoint() returns null.
  • sortAscending() sorts from nearest to farthest. limit(limit) adds COUNT.

Try calling it with Hoan Kiem Lake location:

storeLocatorService.findNearest(105.8522, 21.0287, 2, 5)
        .forEach(s -> System.out.printf("%s: %.2f km%n", s.storeId(), s.distanceKm()));
cong-trang-tien: 0.45 km
cafe-giang: 0.58 km

5.2. Finding Around an Existing Location

Sometimes you want to suggest “other stores near this one.” You don’t need coordinates—just use the member itself as the center:

public List<String> findStoresNear(String storeId, double radiusKm) {
    var results = geoOps.search(
            KEY,
            GeoReference.fromMember(storeId),
            new Distance(radiusKm, Metrics.KILOMETERS),
            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().sortAscending());

    if (results == null) {
        return List.of();
    }

    return results.getContent().stream()
            .map(r -> r.getContent().getName())
            .filter(name -> !name.equals(storeId))
            .toList();
}

GeoReference.fromMember corresponds to FROMMEMBER. The result always includes the original member with distance 0, so you need to filter it out as shown in the filter line.

5.3. Finding Within a Bounding Box

When the user pans the map, you typically need all points within the visible frame, not just within a circle. In this case, you use BoundingBox (corresponding to BYBOX):

public List<String> findInBox(double centerLon, double centerLat,
                              double widthKm, double heightKm) {
    var box = new BoundingBox(
            new Distance(widthKm, Metrics.KILOMETERS),
            new Distance(heightKm, Metrics.KILOMETERS));

    var results = geoOps.search(KEY,
            GeoReference.fromCoordinate(centerLon, centerLat),
            box,
            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs());

    return results == null ? List.of()
            : results.getContent().stream().map(r -> r.getContent().getName()).toList();
}

BoundingBox defines the frame by width and height, with the center being the reference point.

6. Computing Distance and Retrieving Coordinates

Besides searching, GeoOperations supports two other useful operations:

public Distance distanceBetween(String storeA, String storeB) {
    return geoOps.distance(KEY, storeA, storeB, Metrics.KILOMETERS);
}

public Point locationOf(String storeId) {
    List<Point> points = geoOps.position(KEY, storeId);
    return (points == null || points.isEmpty()) ? null : points.get(0);
}
  • distance corresponds to GEODIST. If either member doesn’t exist, the method returns null.
  • position corresponds to GEOPOS. The returned list has null elements for non-existent members.

Coordinates read back will differ slightly from what you stored, for example 105.85470169782638 instead of 105.8547. This is the error from a 52-bit geohash, typically under a meter. Don’t use equals to compare with the original value.

7. Writing Tests with Testcontainers

To run tests on real Redis without depending on your dev machine, use Testcontainers. Add dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Write tests for the main behaviors:

@SpringBootTest
@Testcontainers
class StoreLocatorServiceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
                    .withExposedPorts(6379);

    @Autowired
    StoreLocatorService service;

    @Autowired
    StringRedisTemplate redisTemplate;

    @BeforeEach
    void setUp() {
        redisTemplate.delete(StoreLocatorService.KEY);
        service.addStore("cafe-giang", 105.8547, 21.0334);
        service.addStore("cong-trang-tien", 105.8541, 21.0251);
        service.addStore("highlands-lotte", 105.8128, 21.0318);
    }

    @Test
    void shouldReturnStoresWithinRadiusSortedByDistance() {
        List<NearbyStore> result = service.findNearest(105.8522, 21.0287, 2, 10);

        assertThat(result)
                .extracting(NearbyStore::storeId)
                .containsExactly("cong-trang-tien", "cafe-giang");
        assertThat(result.get(0).distanceKm()).isCloseTo(0.45, within(0.02));
    }

    @Test
    void shouldRespectLimit() {
        assertThat(service.findNearest(105.8522, 21.0287, 10, 1))
                .extracting(NearbyStore::storeId)
                .containsExactly("cong-trang-tien");
    }

    @Test
    void shouldNotFindRemovedStore() {
        service.removeStore("cafe-giang");

        assertThat(service.findNearest(105.8522, 21.0287, 2, 10))
                .extracting(NearbyStore::storeId)
                .doesNotContain("cafe-giang");
    }
}

@ServiceConnection(name = "redis") tells Spring Boot to auto-configure the host and port from the container. You don’t need @DynamicPropertySource. Test distance using isCloseTo(..., within(...)) instead of exact comparison, since results have small error margins as mentioned in section 6.

8. Common Mistakes and When Not to Use Redis Geo

Swapping longitude and latitude. This is mistake number one. Google Maps displays “21.0287, 105.8522” (lat, lon), but Redis and Point need (lon, lat). If you swap them, Redis may report an error like invalid longitude,latitude pair, or worse, silently store a point at the wrong location.

Note: Redis only accepts latitude within ±85.05112878 degrees, per the Web Mercator projection limit. You can’t store locations near the poles.

Storing detailed data in members. Members should be short IDs like store:1024. Don’t embed JSON with names, addresses, or opening hours in members. Store detailed information in Redis Hashes or a database instead. After getting the ID list from GEOSEARCH, fetch details in a single batch call.

Oversized keys. A sorted set with tens of millions of points is a big key. Deleting or migrating this key will block Redis. In Redis Cluster, all points for a key reside on the same shard. You should partition keys by city or region, e.g., coffee:hanoi, coffee:hcm.

No per-member TTL. With driver locations updating continuously, “dead” members persist forever. You need a separate cleanup job, for example, a helper sorted set storing the last update timestamp, then periodically delete stale members.

Large radius without limit. Searching 50 km unlimited in a densely populated city can return tens of thousands of items. You should always set a reasonable limit.

When NOT to use Redis Geo:

  • You need queries on polygons (district boundaries, delivery zones), geometric intersections, or route finding. Use PostGIS or Elasticsearch geo instead.
  • You need to filter on multiple conditions, like “nearest, currently open, has parking.” Redis Geo only filters by distance. You must handle other filtering in your application layer or use Redis Query Engine.
  • Redis is your sole storage with no persistence enabled. Geo should be an accelerating index; your source data should remain in a database.

9. Conclusion

Redis Geo turns the problem of ‘finding the nearest location’ into a few simple commands on a sorted set. You’ve learned how to store coordinates with GEOADD, search by radius or bounding box with GEOSEARCH, and calculate distance with GEODIST. You’ve also bundled everything into a Spring Boot service with Testcontainers tests.

When applying this in practice, remember three things: always pass longitude first, keep members as short IDs, and partition keys by region when data is large. For more complex spatial queries beyond distance, combine Redis with a dedicated geospatial system.

redisspring-bootspring-data-redisgeospatialjava