Redis geospatial commands index named locations and answer nearby-point queries without returning every coordinate to the application. They are useful when spherical distance is an acceptable model. They do not provide road routes, altitude-aware separation, arbitrary polygon analysis, or a guarantee that every returned location is still current.
Longitude comes before latitude
GEOADD takes longitude, latitude, and member name in that order. Valid longitude runs from -180 to 180 degrees; latitude is restricted to approximately -85.05112878 through 85.05112878 degrees. The polar regions are outside this index’s supported range. Swapping two otherwise valid coordinate values may silently store a completely different location.
The index is a sorted set whose scores encode 52-bit geohashes. Do not overwrite these scores with ordinary ranking values. Members are unique: updating the same member changes its position without adding another entry. GEOADD normally counts newly added members; CH counts additions and changes. ZREM removes a location, and GEOPOS reports the stored coordinates.
Coordinates are encoded with finite precision, so GEOPOS need not reproduce the input decimals exactly. GEODIST computes distance on an idealized spherical Earth, not a straight chord through the planet or a road journey. Its default unit is meters, with kilometers, feet, and miles available. A missing endpoint returns nil; it is not a zero-distance pair.
State the search contract
GEOSEARCH, available since Redis 6.2, searches around an existing member or explicit longitude/latitude. BYRADIUS describes a circle; BYBOX describes an axis-aligned box using width and height in the selected unit. Use this command for new queries instead of the deprecated GEORADIUS family. Match units throughout the request and result interpretation.
ASC orders by increasing distance, and COUNT limits returned matches. For nearest results, use ASC with COUNT and avoid ANY. ANY allows stopping after enough matches are found, which does not guarantee the globally nearest matches within the search area. A tiny count also does not guarantee cheap execution across a densely populated, enormous search region.
FROMMEMBER can return the center member itself. An application asking for other nearby people must account for that result. WITHDIST returns distances in the chosen unit, while WITHCOORD returns coordinates. These answers reflect the index contents at query time, not independent verification that a device or business is actually at that position.
When it breaks
The documented spherical approximation can differ from Earth’s actual shape by up to about 0.5 percent in edge cases. A city-scale query is not automatically accurate to one meter. Establish acceptable error and boundary behavior for the application; surveying, safety-critical proximity, routing, or exact geofencing may require a more appropriate spatial model.
GEOSEARCHSTORE writes a result snapshot into another sorted set. By default it preserves position scores; STOREDIST instead writes distances from the query center as scores. That distance-ranked set must not be reused as a geospatial index. Later source updates or removals do not refresh either stored result automatically.
GEOADD does not attach per-location expiration or freshness timestamps. Key expiration removes the entire index, while individual cleanup requires explicit membership management such as ZREM. A fast query can therefore return stale locations perfectly correctly. Define the update and removal policy separately from the spatial search.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. The setup builds Redis core with libc and installs its client in a temporary virtual environment, without optional modules or system-wide installation.
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
Run the Python block with "$redis_lab/venv/bin/python" in the same shell. A private Redis Unix socket under /tmp isolates the experiment from existing instances. TCP, scheduled snapshots, and append-only persistence are disabled. The program stops its own process and removes its runtime directory. Only dependency downloads need network access.
import math
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", "noeviction",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
with instance() as client:
palermo = (13.361389, 38.115556)
catania = (15.087269, 37.502669)
assert client.geoadd("places", [
*palermo, b"Palermo", *catania, b"Catania",
]) == 2
assert client.type("places") == b"zset"
position = client.geopos("places", "Palermo")[0]
assert all(abs(actual - expected) < 0.00001
for actual, expected in zip(position, palermo))
distance = client.geodist("places", "Palermo", "Catania", unit="km")
assert math.isclose(distance, 166.2742, abs_tol=0.001)
assert client.geodist("places", "Palermo", "missing") is None
print(f"longitude first; decoded coordinates are approximate; distance {distance:.4f} km")
query = dict(longitude=15, latitude=37, radius=200, unit="km", sort="ASC")
results = client.geosearch("places", **query, withdist=True)
assert [item[0] for item in results] == [b"Catania", b"Palermo"]
assert results[0][1] < results[1][1] < 200
assert client.geosearch("places", **query, count=1) == [b"Catania"]
assert client.geosearch(
"places", member="Palermo", radius=1, unit="km", sort="ASC",
) == [b"Palermo"]
print("ASC COUNT 1 selects Catania; FROMMEMBER also includes the center member")
assert client.geosearchstore("subset", "places", **query) == 2
assert client.zscore("subset", "Catania") == client.zscore("places", "Catania")
assert client.geosearchstore("distances", "places", **query, storedist=True) == 2
assert math.isclose(client.zscore("distances", "Catania"),
results[0][1], abs_tol=0.001)
assert client.zscore("distances", "Catania") != client.zscore("places", "Catania")
print("default GEOSEARCHSTORE preserves position scores; STOREDIST stores kilometers")
try:
client.geoadd("places", [0, 90, b"pole"])
except redis.ResponseError as error:
assert "invalid longitude,latitude pair" in str(error)
else:
raise AssertionError("latitude 90 must be rejected")
assert client.zcard("places") == 2
assert client.geoadd("swapped", [
*palermo, b"correct", palermo[1], palermo[0], b"reversed",
]) == 2
assert client.geodist("swapped", "correct", "reversed", unit="km") > 1000
print("polar coordinates are rejected; swapped but valid coordinates are not detected")
assert client.geoadd("places", [13.4, 38.1, b"Palermo"]) == 0
assert client.zcard("places") == 2
assert client.geoadd("places", [13.5, 38.2, b"Palermo"], ch=True) == 1
assert client.zrem("places", b"Catania") == 1
assert client.geopos("places", "Catania") == [None]
assert client.zcard("subset") == 2
print("updates reuse members; ZREM removes locations; stored subsets are snapshots")
Palermo and Catania reproduce approximately 166.2742 kilometers, with coordinate comparisons allowing encoding precision. The specified search center ranks Catania first, while a member-centered search includes Palermo itself. Stored position scores match the source; STOREDIST scores instead match the returned kilometer distances.
Latitude 90 is rejected without inserting a member, but reversed coordinates within both valid ranges are accepted and describe a distant point. Updating a member preserves cardinality; removing Catania does not modify the previously stored subset. These results establish command semantics and data interpretation, not real-world positional accuracy, route length, stale-location prevention, or production-scale search performance.
Sources
- Redis GEOADD, coordinate limits, encoding, updates, and spherical-model error, checked 2026-09-08.
- Redis GEOPOS, stored coordinate replies and missing members, checked 2026-09-08.
- Redis GEODIST, spherical distance, units, and absent endpoints, checked 2026-09-08.
- Redis GEOSEARCH, search shapes, ordering, limits, and ANY semantics, checked 2026-09-08.
- Redis GEOSEARCHSTORE, result snapshots and distance-score storage, checked 2026-09-08.
- Redis 8.10.1 build instructions, core-only compilation and allocator selection, checked 2026-09-08.