How to Use Geospatial Data Type in Redis

Updated on July 22, 2023
How to Use Geospatial Data Type in Redis header image

Introduction

Redis is an open-source, in-memory data structure store. Its core data types include String, List, Hash, Set, Sorted Set, HyperLogLog, Bitmap, and Geospatial indexes. The Geospatial data type allows you to store location data in form of coordinates. When stored, you can use Geospatial indexes to query this data in different ways.

This article explains how to use the Geospatial data type to store and query coordinate data using a Vultr Managed Database for Redis. You will use Geospatial commands and build a sample application.

Prerequisites

To follow the instructions in this article, make sure you:

  • Deploy a Vultr Managed Database for Redis by following the Quickstart guide.

  • Deploy a Ubuntu 22.04 Vultr server to manage your connections.

  • Using SSH, access the server as a non-root sudo user.

  • Using Snap, install the Go environment on the server.

      $ snap install go --classic
  • Install jq, a command line JSON processor.

      $ sudo apt-get install jq
  • Install Redis CLI.

      $ sudo apt-get install redis

Geospatial commands

In this section, explore the Redis Geospatial commands and test them using the Redis CLI tool as described below.

Using redis-cli, connect to your Vultr Managed Database for Redis using a connection string.

$ redis-cli -u rediss://default:[DATABASE_PASSWORD]@[DATABASE_HOST]:[DATABASE_PORT]

Replace DATABASE_PASSWORD, DATABASE_HOST, and DATABASE_PORT with the actual values from your connection string.

When connected you should access your Redis Database console with the > prompt.

Add Geospatial Data (GEOADD)

The GEOADD command is used to add longitude, latitude, and name to a specified key. Add the coordinates for London and Paris to a key named Cities:

> GEOADD Cities -0.127758 51.507351 "London"
> GEOADD Cities 2.352222 48.856614 "Paris"

The following output is displayed for both cases:

(integer) 1

Retrieve Geospatial Data (GEOPOS)

GEOPOS retrieves the longitude and latitude of one or more elements from the Geospatial index represented by a specified key.

To get the Geospatial coordinates of London and Paris, run the following command.

> GEOPOS Cities "London" "Paris"

Output:

1) 1) "-0.12775629758834839"
  1) "51.50735008547815141"
2) 1) "2.35221952199935913"
  1) "48.85661473867625659"

Calculate Distance (GEODIST)

Redis can calculate the distance between two elements in a Geospatial index using the GEODIST command. It supports various units for distance such as meters (m), kilometers (km), miles (mi), and feet (ft).

To calculate the distance between London and Paris in kilometers, run the command below.

> GEODIST Cities "London" "Paris" km

Output:

"343.6460"

Retrieve Nearby Elements (GEORADIUS)

The GEORADIUS command fetches elements located within a specified radius of a given point. This could be particularly useful for a location-based service where you might want to find places within a certain distance of a user's location.

Here's how to find cities within a 500 km radius of a point (longitude: 2.3522, latitude: 48.8566):

> GEORADIUS Cities 2.3522 48.8566 500 km WITHDIST WITHCOORD

Output:

1) 1) "Paris"
   2) "0.0022"
   3) 1) "2.35221952199935913"
      2) "48.85661473867625659"
2) 1) "London"
   2) "343.6467"
   3) 1) "-0.12775629758834839"
      2) "51.50735008547815141"

Retrieve Nearby Elements By Member (GEORADIUSBYMEMBER)

GEORADIUSBYMEMBER is similar to GEORADIUS, but provides the longitude and latitude of an element, you can specify a member already stored in the Geospatial index. For example, to find cities within a 500 km radius of "Paris", run the following command.

> GEORADIUSBYMEMBER Cities "Paris" 500 km WITHDIST WITHCOORD

Output:

1) 1) "Paris"
   2) "0.0000"
   3) 1) "2.35221952199935913"
      2) "48.85661473867625659"
2) 1) "London"
   2) "343.6460"
   3) 1) "-0.12775629758834839"
      2) "51.50735008547815141"

Geospatial Hash (GEOHASH)

GEOHASH provides a Geohash string representing the position of one or more elements in the Geospatial index.

For example, to get the Geohashes for "London" and "Paris", run the command below.

> GEOHASH Cities "London" "Paris"

Output:

1) "gcpvj0dup10"
2) "u09tvw0f6t0"

Geospatial Search (GEOSEARCH)

GEOSEARCH is an upgraded version of GEORADIUS and GEORADIUSBYMEMBER with more flexible options. You can perform a radial search (like GEORADIUS), member-based search (like GEORADIUSBYMEMBER), or rectangular search by providing the southwest and northeast coordinates of a rectangle.

For example, to find cities within a 500 km radius of "Paris", run the command below.

> GEOSEARCH Cities FROMMEMBER "Paris" BYRADIUS 500 km

Output:

1) "Paris"
2) "London"

Geospatial Search and Store (GEOSEARCHSTORE)

GEOSEARCHSTORE is similar to GEOSEARCH, but instead of returning the results, it stores them in a destination key.

  1. To store the names of cities within a 500 km radius of Paris in a key named NearbyCities, run:

     > GEOSEARCHSTORE NearbyCities Cities FROMMEMBER "Paris" BYRADIUS 500 km STOREDIST

    Output:

     (integer) 2
  2. NearbyCities is a Sorted Set. Use the command below to query it.

     > ZRANGE NearbyCities 0 1 WITHSCORES

    Your Output should look like the one below.

     1) "Paris"
     2) "0"
     3) "London"
     4) "343.64597544862346"
  3. Exit the Redis console.

     > QUIT

Create a Sample application

In this section, the sample application is an API for a service that allows users to find coffee shops close to them. It consists of the following endpoints.

  • A REST endpoint to load location data for coffee shops: For simplicity, the store location loading is a one-time process that can be invoked on demand because it’s exposed as a REST endpoint. Once invoked, the shop data is read from a JSON file and saved in a Redis hash (one for each shop).
  • A REST endpoint to locate coffee shops: This endpoint provides the ability to search for stores within a given radius of a specific location (latitude and longitude). Let’s imagine that you are looking for coffee shops within five miles of your location. A web or mobile application would pass the coordinates of your current location (for example, latitude: 40, longitude: -83) and send an HTTP request to the back-end application, which looks similar to: localhost:8080/search?lat=40&long=-83&dist=5

Initialize the project

  1. Create a new project directory.

     $ mkdir redis-geospatial-coffee-shop
  2. Switch to the directory.

     $ cd redis-geospatial-coffee-shop
  3. Create a new Go module.

     $ go mod init redis-geospatial-coffee-shop

    The above creates a new go.mod file in your directory that stores environment dependencies.

  4. Create a new file main.go.

     $ touch main.go
  5. Using a text editor such as nano, open the file.

     $ nano main.go

Import libraries

Add the following code to the main.go file.

package main

import (
    "bufio"
    "context"
    "crypto/tls"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"

    "github.com/redis/go-redis/v9"
)

Add the init function and global variables

Append the following code to the file.

var client *redis.Client
const fileName = "store_locations.json"
const geoName = "store-locations"
const hashNamePrefix = "store:"

func init() {

    redisURL := os.Getenv("REDIS_URL")
    if redisURL == "" {
        log.Fatal("missing environment variable REDIS_URL")
    }

    opt, err := redis.ParseURL(redisURL)
    if err != nil {
        log.Fatal("invalid redis url", err)
    }

    opt.TLSConfig = &tls.Config{}

    client = redis.NewClient(opt)

    _, err = client.Ping(context.Background()).Result()

    if err != nil {
        log.Fatal("ping failed. could not connect", err)
    }

    fmt.Println("successfully connected to redis")
}
  • The init function reads the Vultr Managed Database for Redis URL from the REDIS_URL environment variable and fails if it's missing.
  • A new redis.Client instance is created, the connection to the Redis database is verified using the Ping utility. If the connection fails, the program exits with an error message.

Add the load HTTP handler function

Add the load function to the file.

func load(w http.ResponseWriter, req *http.Request) {

    fmt.Println("loading location data")

    f, err := os.Open(fileName)

    if err != nil {
        fmt.Println("cannot file", err)
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    defer f.Close()

    var stores StoreData

    err = json.NewDecoder(bufio.NewReader(f)).Decode(&stores)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    pipe := client.Pipeline()

    for _, store := range stores {
        err = pipe.GeoAdd(req.Context(), geoName, &redis.GeoLocation{Name: store.Name, Latitude: store.Position.Lat, Longitude: store.Position.Lng}).Err()
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        parts := strings.Split(store.Name, " ")
        storeid := parts[len(parts)-1]
        storeHashName := hashNamePrefix + storeid

        err = pipe.HSet(req.Context(), storeHashName, "name", store.Name, "address", store.Address, "phone", store.Phone).Err()
        if err != nil {
            fmt.Println("failed to add store data", err)
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
    }

    _, err = pipe.Exec(req.Context())
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    fmt.Println("geospatial data load complete")
}

The load function is an HTTP handler that stores coffee location data in a Geospatial data structure and other metadata in a Redis hash. In the above confuguration:

  • The function reads the JSON file containing the coffee shop data.
  • Contents of the JSON file are decoded into a StoreData type variable called stores using a new JSON decoder. The JSON decoder reads from a buffered reader that wraps the file.
  • client.Pipeline() creates a pipeline that allows you to send multiple commands to the Redis database without waiting for replies, this improves the data loading performance in return.
  • For every data in the JSON file:
    • Add the store's coordinates to the Redis geospatial data store using pipe.GeoAdd().
    • Split up the store.Name into parts by spaces, retrieve the storeid as the last part of the split name, and constructs the storeHashName as the concatenation of hashNamePrefix and storeid.
    • Add a new hash in Redis using pipe.HSet(), with storeHashName as the key and the fields name, address, and phone set to the corresponding properties of store.
    • Execute all commands in the pipeline using pipe.Exec().

Add custom types

Add the SearchResponse and StoreData structs to the file.

type SearchResponse struct {
    Name     string  `json:"name"`
    Distance float64 `json:"distance"`
    Address  string  `json:"address"`
    Phone    string  `json:"phone,omitempty"`
    Lat      float64 `json:"lat"`
    Long     float64 `json:"long"`
}

type StoreData []struct {
    Position struct {
        Lat float64 `json:"lat"`
        Lng float64 `json:"lng"`
    } `json:"position"`
    Name    string `json:"name"`
    Address string `json:"address"`
    Phone   string `json:"phone"`
}
  • The StoreData struct represents the data in the JSON file. It contains the position, name, address, and phone of the store.
  • The SearchResponse struct represents the response to the search request. It contains the name, distance, address, phone, latitude, and longitude of the store.

Add the search HTTP handler function

Add the search function to the file.

func search(w http.ResponseWriter, req *http.Request) {
    _lat := req.URL.Query().Get("lat")
    _long := req.URL.Query().Get("long")
    _dist := req.URL.Query().Get("dist")

    fmt.Println("searching for stores within", _dist, "miles of lat:", _lat, "long:", _long)

    lat, _ := strconv.ParseFloat(_lat, 64)
    long, _ := strconv.ParseFloat(_long, 64)
    dist, _ := strconv.ParseFloat(_dist, 64)

    stores := client.GeoSearchLocation(req.Context(), geoName,
        &redis.GeoSearchLocationQuery{
            GeoSearchQuery: redis.GeoSearchQuery{
                Longitude:  float64(long),
                Latitude:   float64(lat),
                Radius:     float64(dist),
                RadiusUnit: "mi",
                Sort:       "ASC"},
            WithCoord: true,
            WithDist:  true}).
        Val()

    var results []SearchResponse

    for _, store := range stores {
        fmt.Println(store.Name)

        parts := strings.Split(store.Name, " ")
        storeid := parts[len(parts)-1]
        storeHashName := hashNamePrefix + storeid

        storeInfo, err := client.HMGet(req.Context(), storeHashName, "address", "phone").Result()
        if err != nil {
            fmt.Println("failed to get store info for", storeHashName, err)
            http.Error(w, err.Error(), http.StatusInternalServerError)
        }
        results = append(results, SearchResponse{Name: store.Name, Distance: store.Dist, Address: storeInfo[0].(string), Phone: storeInfo[1].(string), Lat: store.Latitude, Long: store.Longitude})
    }

    err := json.NewEncoder(w).Encode(results)
    if err != nil {
        fmt.Println("failed to encode search response", err)
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

The search function is an HTTP handler that searches for coffee shops within a given distance from a given location.

  • First, fetches latitude, longitude, and distance from the query parameters of the request.
  • client.GeoSearchLocation() is used to search for stores within the given distance from the given location. The GeoSearchLocation() function returns a slice of redis.GeoLocation structs, each containing the name, distance, latitude, and longitude of a store.
  • For each store in the search results:
    • Split up the store.Name into parts by spaces, retrieve the storeid as the last part of the split name, and construct the storeHashName as the concatenation of hashNamePrefix and storeid.
    • Retrieve the address and phone of the store from the Redis hash using client.HMGet().
    • Construct a SearchResponse struct using the store name, distance, address, phone, latitude, and longitude.
    • Add the SearchResponse struct to the results slice.
    • The results slice is encoded into JSON, write it to the response.

Add the main function

Add the main function to file.

func main() {

    http.HandleFunc("/load", load)
    http.HandleFunc("/search", search)

    fmt.Println("started http server")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

The main function is the program's entry point. It registers the /load, /search endpoints, and starts an HTTP server that listens for requests on port 8080.

Save and close the main.go file to apply all changes.

Run the program

  1. Fetch the JSON file with the location data for coffee shops.

     $ curl https://gist.githubusercontent.com/wf9a5m75/3d40f2d6814ef5f2bc4f75f61d86741e/raw/b05457e72fa261f7a31c0315eb0340faab454121/starbucks_us_locations.json -o store_locations.json

    A store_locations.json file is added to your directory.

  2. Fetch the Go module dependencies for the program.

     $ go get
  3. Get your Vultr Managed Database for Redis URL and set it as below.

     $ export REDIS_URL=rediss://default:[DATABASE_PASSWORD]@[DATABASE_HOST]:[DATABASE_PORT]

    Replace DATABASE_PASSWORD, DATABASE_HOST, and DATABASE_PORT with your actual database values.

  4. Run the program in the background.

     $ go run main.go &

    Output:

     successfully connected to redis
     started http server
  5. Load the coffee shop’s location data.

     $ curl http://localhost:8080/load

    When successful, your output should look like the one below in the application logs.

     loading location data
     geospatial data load complete
  6. Search for coffee shops within two miles of the latitude 40.01390852 and the longitude -83.01119585.

     $ curl "localhost:8080/search?lat=40.01390852&long=-83.01119585&dist=2" | jq

    Your JSON response should look like the one below.

     [
         {
             "name": "Starbucks - OH - Columbus [W]  06432",
             "distance": 0,
             "address": "Broadway & N. High_3416 North High Street_Columbus, Ohio 43202",
             "phone": "614-263-1292",
             "lat": 40.013907925850454,
             "long": -83.01119595766068
         },
         {
             "name": "Starbucks - OH - Columbus  06412",
             "distance": 0.399,
             "address": "Giant Eagle-Columbus #6512_2801 N High St_Columbus, Ohio 43202",
             "lat": 40.01957302764158,
             "long": -83.01265507936478
         },
         {
             "name": "Starbucks - OH - Columbus [WD]  06425",
             "distance": 1.7259,
             "address": "Lennox Station_1570 Olentagy Road_Columbus, Ohio 43123",
             "phone": "614-299-0625",
             "lat": 39.99160998381175,
             "long": -83.02587300539017
         },
         {
             "name": "Starbucks - OH - Columbus [WD]  06423",
             "distance": 1.9337,
             "address": "Fiesta Lane - Lane Ave._1315 W Lane Ave_Columbus, Ohio 43221",
             "phone": "614-485-1202",
             "lat": 40.006721991363726,
             "long": -83.04649919271469
         }
     ]

    As displayed in the output, the search query returns four coffee shops in increasing order of distance (in miles) from the search coordinates.

  7. Change the search coordinates and increase the search radius to 10 miles:

     $ curl "localhost:8080/search?lat=34.75911&long=-92.379005&dist=10" | jq

    Your JSON response should look like the one below.

     [
         {
             "name": "Starbucks - AR - Little Rock [D]  00094",
             "distance": 0.0001,
             "address": "Rodney Parham & Treasure Hill_9401 N. Rodney Parham Rd_Little Rock, Arkansas 72227",
             "phone": "501-223-9177",
             "lat": 34.75910905295357,
             "long": -92.37900406122208
         },
         {
             "name": "Starbucks - AR - Little Rock  00090",
             "distance": 0.9364,
             "address": "Baptist Health - Little Rock_9601 Lile Drive_Little Rock, Arkansas 72205",
             "phone": "501-202-2000",
             "lat": 34.74577388493429,
             "long": -92.38191694021225
         },
         {
             "name": "Starbucks - AR - Little Rock [WD]  00095",
             "distance": 1.4906,
             "address": "Cantrell & Mississippi_7525 Cantrell Rd_Little Rock, Arkansas 72207",
             "phone": "501-603-0094",
             "lat": 34.77140498529753,
             "long": -92.35743373632431
         },
         {
             "name": "Starbucks - AR - Little Rock [W]  00096",
             "distance": 1.6182,
             "address": "Chenal & Financial Center_11401 Financial Centre Parkway, Suite D_Little Rock, Arkansas 72211",
             "phone": "501-219-2337",
             "lat": 34.7481108978432,
             "long": -92.40416318178177
         },
         {
             "name": "Starbucks - AR - Little Rock  00091",
             "distance": 1.9743,
             "address": "Kroger - Little Rock AR #608_16105 D Chenal Pkwy_Little Rock, Arkansas 72211",
             "lat": 34.752042250361335,
             "long": -92.41269260644913
         },
         {
             "name": "Starbucks - AR - Little Rock [W]  00098",
             "distance": 2.1933,
             "address": "University & W. Markham_201 N University Ave_Little Rock, Arkansas 72205",
             "phone": "501-664-4865",
             "lat": 34.75300797912305,
             "long": -92.34109908342361
         },
         {
             "name": "Starbucks - AR - Little Rock [W]  00097",
             "distance": 2.493,
             "address": "Kavanaugh & Pierce_5719 Kavanaugh Street_Little Rock, Arkansas 72207",
             "phone": "501-603-9230",
             "lat": 34.77022127051611,
             "long": -92.33723133802414
         },
         {
             "name": "Starbucks - AR - Little Rock [D]  00093",
             "distance": 3.3727,
             "address": "Hwy 10 & Sam Peck_12901 Cantrell_Little Rock, Arkansas 72227",
             "phone": "501-954-7596",
             "lat": 34.79799927970131,
             "long": -92.41489738225937
         },
         {
             "name": "Starbucks - AR - North LIttle Rock [D]  00099",
             "distance": 7.5621,
             "address": "JFK & McCain_4824 JFK_North LIttle Rock, Arkansas 72116",
             "phone": "501-812-0913",
             "lat": 34.799299591656045,
             "long": -92.25510209798813
         },
         {
             "name": "Starbucks - AR - North Little Rock  00100",
             "distance": 9.0706,
             "address": "Hwy 67 & McCain_4120 E. McCain_North Little Rock, Arkansas 72116",
             "phone": "501-955-3450",
             "lat": 34.78946740827897,
             "long": -92.22355931997299
         },
         {
             "name": "Starbucks - AR - Little Rock [A]  00092",
             "distance": 9.3397,
             "address": "LIT - Little Rock Simply Books_1 Airport Rd_Little Rock, Arkansas 71202",
             "lat": 34.727693718904725,
             "long": -92.21905320882797
         }
     ]

    In the above output, the search query returns multiple coffee shops in increasing order of distance (miles) from the search coordinates.

    To see how the search results change, try other search coordinates and radius values to see how the search results change. To stop the application, verify the background job number, and kill the job id as below.

     $ kill %1

Conclusion

You have used a Vultr Managed Database for Redis as the backend datastore for an application that allows you to store and query coffee shop data based on the location. You also covered the basics of how to use Geospatial commands such as GEOADD, GEORADIUS, and more. For more information, visit the following resources.