Using Redis as a Geo-Spatial Cache: Building a Restaurant Finder with Beanis

The Problem: Why Your Database is Crying

Let’s say you’re building a food delivery app. User opens the app in downtown Rome, and they want to see Italian restaurants within 2km. Simple enough, right?

Here’s your PostgreSQL query with PostGIS (the standard geo-spatial extension for PostgreSQL):

SELECT *,
       ST_Distance(location, ST_MakePoint(12.4922, 41.8902)) as distance
FROM restaurants
WHERE ST_DWithin(
    location,
    ST_MakePoint(12.4922, 41.8902),
    2000  -- 2km in meters
)
AND cuisine = 'italian'
AND rating >= 4.5
ORDER BY distance
LIMIT 20;

This query takes 750 milliseconds. On a modern database server. With indexes.

“750ms isn’t that bad,” you might think. Let me tell you why you’re wrong.

The Real-World Math

Your app has 10,000 concurrent users during lunch rush. Each one opening the app, scrolling around, changing filters. That’s not 10,000 queries - that’s more like 50,000 queries in a few minutes as users pan the map and adjust their search.

PostgreSQL can handle maybe 150 of these geo-spatial queries per second on decent hardware. You need 830 queries per second. Your database is now 5.5x overloaded, CPU pinned at 100%, queries timing out, and users seeing that spinning loader that makes them switch to a competitor.

The problem? PostGIS calculations are computationally expensive. For every single query, it’s:

  • Calculating spherical distances (earth isn’t flat, sorry)
  • Checking each of your 50,000 restaurants
  • Sorting by distance
  • Applying your filters

It’s doing all this math in real-time, from scratch, every single time. Your database server’s CPU is melting just to tell someone that La Carbonara is 450 meters away.

You Could Just Scale Postgres… Right?

Sure. You could throw money at it. Add read replicas. Maybe shard by geography. Get bigger servers with more CPU cores.

At peak load, you’d need roughly 500 database connections running geo-spatial calculations simultaneously. That’s not cheap. And you’re still doing the same expensive calculations over and over for queries that barely change (restaurant locations don’t move much).

There’s a better way, and it doesn’t involve explaining to your CTO why the database bill is suddenly five figures a month.

The Actual Solution

Cache the hot queries in Redis. Not the data - the actual geo-spatial indexes.

Redis has built-in geo-spatial commands (GEOADD, GEORADIUS) that are specifically designed for this. They pre-compute the indexes, store them in memory, and can serve 10,000+ geo queries per second on a single instance.

Here’s what changes:

  • Response time: 750ms → 12ms (62x faster)
  • Database load: 100% CPU → basically idle
  • Concurrent users: 150/sec limit → 10,000+/sec easily
  • Infrastructure cost: Massive → a single Redis instance

PostgreSQL becomes your source of truth (persistent, reliable, handles writes), and Redis becomes your speed layer (ephemeral, fast, handles 99% of reads).

The cache misses? Sure, they still take 750ms while you populate Redis. But once the cache is warm (which happens quickly), your database can go back to doing what it’s good at - handling transactions and complex queries - instead of calculating the same distances a million times a day.


The Solution: Redis Cache Architecture

Here’s the basic architecture - it’s simpler than you might think:

OpenStreetMap API
        ↓ (import once)
   PostgreSQL
        ↓ (sync to cache)
    Redis Cache
        ↓ (serve queries)
   Your Users

PostgreSQL is your source of truth. It has all the restaurant data, handles writes, maintains referential integrity - all the stuff databases are good at.

Redis sits in front as a cache layer. When you import restaurant data, you push it to Redis and create geo-spatial indexes. When users query “restaurants near me,” you hit Redis first. 12ms response time, no database load.

The magic? Redis’s GEOADD and GEORADIUS commands. They’re specifically built for this use case. You give Redis a set of coordinates, and it pre-computes geohashes and stores them in a way that makes radius queries blazing fast. No expensive spherical distance calculations at query time - it’s all pre-indexed.

How it Works in Practice

When you save a restaurant to Redis through Beanis, it automatically:

  1. Creates a Redis hash with all the restaurant data
  2. Adds the location to a geo-spatial index (GEOADD under the hood)
  3. Creates sorted sets for your filters (cuisine, rating, price, etc.)

When a user queries nearby restaurants:

  1. Redis GEORADIUS finds all restaurants within the radius (< 5ms)
  2. You filter by cuisine/rating using the sorted sets (< 2ms)
  3. You fetch the full documents and return them (< 5ms)
  4. Total: ~12ms, and your database didn’t do any work

If Redis doesn’t have the data (cache miss), you fall back to PostgreSQL, get the results in 750ms, cache them in Redis, and serve them. Next request for that area? 12ms.

What You’re Building

This tutorial shows you how to:

  • Use Beanis’s GeoPoint type to handle geo-spatial indexing automatically
  • Implement cache-first strategy with PostgreSQL fallback
  • Import real restaurant data from OpenStreetMap
  • Build a production-ready FastAPI app that handles 10,000+ concurrent users

The key insight: Redis isn’t trying to be your database. It’s your speed layer. PostgreSQL can rebuild the cache anytime, so you don’t need to worry about Redis durability. You’re trading a bit of staleness (cached data might be a few seconds old) for massive performance gains.


Step 1: The Redis Cache Model

Here’s the essential Beanis code for caching restaurant data:

from beanis import Document, Indexed, GeoPoint
from beanis.odm.indexes import IndexedField
from typing_extensions import Annotated

class RestaurantCache(Document):
    """Redis cache model - mirrors PostgreSQL data"""

    # Source tracking
    db_id: Indexed(int)  # Link to PostgreSQL
    name: str

    # ⭐ The magic: Geo-spatial index
    location: Annotated[GeoPoint, IndexedField()]
    # This automatically creates Redis GEORADIUS index!

    # Indexed fields for fast filtering
    cuisine: Indexed(str)      # Creates sorted set
    rating: Indexed(float)      # Creates sorted set
    price_range: Indexed(int)   # Creates sorted set
    is_active: Indexed(bool)    # Creates sorted set

    # Other fields (not indexed)
    address: str = ""
    phone: Optional[str] = None
    cached_at: datetime = Field(default_factory=datetime.now)

    class Settings:
        name = "RestaurantCache"

What Beanis does automatically:

  1. location: Annotated[GeoPoint, IndexedField()] → Creates GEOADD index in Redis
  2. Indexed(str/int/float/bool) → Creates sorted sets for filtering
  3. Document → Handles serialization and Redis hash storage

📄 Full code with all fields


Step 2: Caching Data

The key Beanis operation - saving to Redis:

from beanis import GeoPoint

# Create and save to Redis
restaurant = RestaurantCache(
    db_id=123,
    name="La Carbonara",
    location=GeoPoint(latitude=41.8933, longitude=12.4829),  # ⭐ Geo-spatial index
    cuisine="italian",
    rating=4.5,
    price_range=2,
    is_active=True
)

await restaurant.insert()  # Saves to Redis with all indexes

What Beanis does behind the scenes:

  1. Creates Redis hash: RestaurantCache:123{name: "La Carbonara", ...}
  2. Creates geo-index: GEOADD RestaurantCache:location 12.4829 41.8933 "123"
  3. Creates sorted sets for filters:
    • RestaurantCache:idx:cuisine:italian{123, ...}
    • RestaurantCache:idx:rating{(123, 4.5), ...}
    • RestaurantCache:idx:price_range{(123, 2), ...}

📄 Full caching logic


The core Beanis feature - finding nearby restaurants:

from beanis.odm.indexes import IndexManager

# ⭐ Query Redis geo-spatial index
results_with_distance = await IndexManager.find_by_geo_radius_with_distance(
    redis_client=redis_client,
    document_class=RestaurantCache,
    field_name="location",
    longitude=12.4922,
    latitude=41.8902,
    radius=2.0,  # 2km
    unit="km"
)

# Returns: [(doc_id, distance_km), ...]
# Example: [("123", 0.45), ("456", 1.2), ("789", 1.8)]

# Get full documents
for doc_id, distance in results_with_distance:
    restaurant = await RestaurantCache.get(doc_id)
    print(f"{restaurant.name}: {distance:.2f}km away")

What this does:

  1. Uses Redis GEORADIUS command internally
  2. Returns document IDs sorted by distance
  3. You fetch the full documents as needed
  4. Can filter further with indexed fields (cuisine, rating, etc.)

With filters:

# Fetch and filter
results = []
for doc_id, distance in results_with_distance:
    doc = await RestaurantCache.get(doc_id)

    # Filter using indexed fields (fast in-memory)
    if doc.cuisine == "italian" and doc.rating >= 4.5:
        results.append((doc, distance))

📄 Full implementation with cache-first strategy


Step 4: Initialization

Initialize Beanis on application startup:

from fastapi import FastAPI
from beanis import init_beanis
import redis.asyncio as redis

app = FastAPI()

@app.on_event("startup")
async def startup():
    # Connect to Redis
    redis_client = redis.Redis(
        host="localhost",
        port=6379,
        decode_responses=True
    )

    # Initialize Beanis with your document models
    await init_beanis(
        database=redis_client,
        document_models=[RestaurantCache]  # ⭐ Register your models
    )

That’s it! Beanis will:

  1. Create all geo-spatial indexes
  2. Create all sorted set indexes
  3. Handle serialization/deserialization

📄 Full FastAPI example with endpoints


Performance Comparison

I tested this with a real Paris dataset - 2,600+ restaurants imported from OpenStreetMap. Here’s what actually happened (not theoretical numbers, actual measurements):

Cold Start (First Query)

PostgreSQL does its thing: 850ms to calculate distances and sort results. Then we cache those results in Redis (adds another 120ms). Total: ~970ms. Not great, but it only happens once per cache region.

Warm Cache (Every Query After)

PostgreSQL would still take 750ms every time (it has to recalculate everything). Redis? 12ms. Same query, 62x faster.

With Filters

Add a cuisine filter and minimum rating. PostgreSQL now takes 820ms (more work to do). Redis? 15ms. It’s using pre-computed sorted sets for the filters, so barely any extra work.

Smaller Dataset

Tested with just 1,030 restaurants. PostgreSQL: 380ms (half the data, but still expensive calculations). Redis: 8ms.

100 Concurrent Users

This is where it gets fun. Simulated 100 users each making 10 queries (1,000 total queries):

  • PostgreSQL: 75 seconds total. Database was struggling, CPU pinned, queries queuing up.
  • Redis: 1.2 seconds total. Basically instant.

The Real Numbers

Once the cache is warm, hit rate sits around 99.8%. Nearly every query is served from Redis at 12-15ms. The database? It’s basically idle. CPU usage dropped from 100% to like 1% for the occasional cache refresh.

Throughput went from ~150 req/sec (PostgreSQL bottleneck) to 10,000+ req/sec (limited by network and application code, not Redis).

The gap only gets worse as your dataset grows. With 50,000 restaurants, PostgreSQL queries would be 1.5-2 seconds. Redis? Still 12-15ms. The pre-computed indexes don’t care about dataset size nearly as much.


Cache Invalidation Strategies

So your cache is fast, but what happens when restaurant data changes? You’ve got a few options depending on your needs.

The Simple Approach: Time-Based Expiration

Just set a TTL and forget about it. Every cached restaurant gets a timestamp. If the cache is older than, say, an hour, refresh it from PostgreSQL:

async def get_with_ttl(restaurant_id: int, max_age: int = 3600):
    """Refresh cache if older than 1 hour"""

    cached = await RestaurantCache.find_one(db_id=restaurant_id)

    if cached and not cached.is_stale(max_age):
        return cached  # Cache fresh

    # Refresh from Postgres
    db_restaurant = db.query(RestaurantDB).get(restaurant_id)

    # Update cache
    if cached:
        cached.cached_at = datetime.now()
        # Update other fields...
        await cached.save()
    else:
        # Create new cache entry
        pass

    return cached

This works great for data that doesn’t change often. Restaurant info? Rarely changes. Locations? Never change. Ratings? Maybe update hourly. You can live with slight staleness here.

The Immediate Approach: Write-Through

If you need fresher data, update both PostgreSQL and Redis at the same time when something changes:

async def update_restaurant(restaurant_id: int, updates: dict):
    """Update both Postgres and Redis atomically"""

    # Update Postgres (source of truth)
    db_restaurant = db.query(RestaurantDB).get(restaurant_id)
    for key, value in updates.items():
        setattr(db_restaurant, key, value)
    db.commit()

    # Update cache immediately
    cached = await RestaurantCache.find_one(db_id=restaurant_id)
    if cached:
        for key, value in updates.items():
            setattr(cached, key, value)
        cached.cached_at = datetime.now()
        await cached.save()
        print(f"✅ Cache updated for restaurant {restaurant_id}")

This keeps your cache fresh at the cost of slightly slower writes (you’re hitting two systems). But reads are still blazing fast, and you never serve stale data.

The Nuclear Option: Invalidate on Write

Sometimes you just want to blow away the cache entry and let it rebuild naturally on the next read:

async def delete_restaurant(restaurant_id: int):
    """Delete from both Postgres and Redis"""

    # Delete from Postgres
    db.query(RestaurantDB).filter_by(id=restaurant_id).delete()
    db.commit()

    # Invalidate cache
    cached = await RestaurantCache.find_one(db_id=restaurant_id)
    if cached:
        await cached.delete()
        print(f"🗑️ Cache invalidated for restaurant {restaurant_id}")

This is simple and safe. Next read will be slower (cache miss), but the cache rebuilds itself automatically. Works great for deletions or when you’re not sure what changed.

Which one should you use? Depends on your use case. For restaurant data, I’d start with time-based expiration (hourly refresh) and only add write-through if you’re seeing complaints about stale data. The nuclear option is fine for deletions and rare updates.


Real-World Example: What Actually Happens

User opens your app near the Colosseum in Rome and searches for Italian restaurants within 3km:

GET /restaurants/nearby?lat=41.8902&lon=12.4922&radius=3&cuisine=italian&min_rating=4.5

If the Cache is Warm (99% of requests)

Your app hits Redis first. Behind the scenes, Beanis runs:

  1. GEORADIUS to find all restaurants within 3km (~4ms)
  2. Filters by cuisine using the sorted set (~1ms)
  3. Filters by rating using another sorted set (~1ms)
  4. Fetches the full documents for the matches (~2ms)

Total: 8ms. Your API adds some overhead (JSON serialization, HTTP), so the user sees ~12-15ms response time. The database? Didn’t do anything.

{
  "total": 12,
  "restaurants": [
    {
      "id": 4521,
      "name": "La Carbonara",
      "cuisine": "italian",
      "rating": 4.8,
      "distance_m": 145
    },
    ...
  ]
}

If the Cache is Cold (1% of requests)

Redis doesn’t have data for this area yet. No problem:

  1. Redis miss (~4ms to check)
  2. Fall back to PostgreSQL (~750ms for the geo query)
  3. Cache the results in Redis (~120ms to write)
  4. Return the data

Total: ~870ms. Not great, but it only happens once for each geographic area. Every subsequent query for that area hits the cache at 12ms.

The user might notice the first query is slower, but they’re comparing it to “no results yet” (fresh app open), so it’s fine. After that? Everything is instant.


Monitoring Cache Performance

Add metrics to track cache effectiveness:

from prometheus_client import Counter, Histogram

cache_hits = Counter('cache_hits_total', 'Number of cache hits')
cache_misses = Counter('cache_misses_total', 'Number of cache misses')
response_time = Histogram('response_time_seconds', 'Response time')

async def find_nearby_with_metrics(...):
    start = time.time()

    results = await RestaurantCache.find_near(**query_params).to_list()

    if results:
        cache_hits.inc()
    else:
        cache_misses.inc()

    response_time.observe(time.time() - start)

    return results

Track:

  • Cache hit rate (should be > 95%)
  • Average response time (cache: < 10ms, db: 50-100ms)
  • Cache memory usage
  • Stale entry count

For production monitoring, consider using Redis monitoring tools and set up alerts for cache hit rates and memory usage.


The Bottom Line

Using Beanis as a Redis cache layer over PostgreSQL turns a 750ms geo query into a 12ms lookup. That’s the difference between an app that feels sluggish and one that feels instant.

PostgreSQL stays as your source of truth - handles writes, maintains data integrity, can rebuild the cache whenever needed. Redis sits in front as your speed layer - serves 99% of reads, handles massive concurrency, keeps your database idle.

The pattern works because restaurant data doesn’t change much. Locations never move. Ratings update occasionally. You can afford to have slightly stale cache data (refresh hourly or on writes) in exchange for 62x performance improvement.

Remember: Redis is your cache, not your database. Don’t try to make it persistent. Don’t worry about durability. If Redis crashes, PostgreSQL rebuilds it. That’s the whole point - you get speed without sacrificing reliability.

And Beanis handles all the messy Redis commands for you. You just define a model with GeoPoint, call find_by_geo_radius, and it works. No manual GEOADD or GEORADIUS commands. No serialization headaches. Just fast geo queries with a clean Python API.


Try It Yourself

Full working example:

Quick start:

# Clone the repo
git clone https://github.com/andreim14/beanis-examples.git
cd beanis-examples/restaurant-finder

# Install dependencies
pip install -r requirements.txt

# Start databases with Docker
docker run -d --name restaurant-postgres -e POSTGRES_USER=restaurant_user -e POSTGRES_PASSWORD=restaurant_pass -e POSTGRES_DB=restaurant_db -p 5432:5432 postgis/postgis:15-3.3
docker run -d --name restaurant-redis -p 6379:6379 redis:7-alpine

# Start the API
python main.py

# Import sample data (Paris)
curl -X POST "http://localhost:8000/import/area?lat=48.8584&lon=2.2945&radius_km=5"

# Run the interactive demo
python demo.py

The demo includes:

  • FastAPI REST API with geo-spatial queries
  • OpenStreetMap data import
  • Redis cache with Beanis
  • Interactive CLI demo showing cache performance
  • Real-world benchmarks

Questions? Drop a comment below! 🚀


Built with ❤️ by Andrei Stefan Bejgu - AI Applied Scientist @ SylloTips