Skip to content

Relationship Field

The Relationship field creates references between model instances, similar to foreign keys in SQL databases. Unlike SQL, Redis has no JOIN operation. Popoto stores a reference (the related instance's Redis key) and lazy-loads the full object on access. This keeps writes fast and avoids loading data you never use, but multi-model queries work differently than in Django or SQLAlchemy.

Basic Usage

A Relationship field points from one model to another. In a food delivery system, a MenuItem belongs to a Restaurant and an Order references a Customer, a Restaurant, and optionally a Driver.

from popoto import (
    Model, KeyField, AutoKeyField, UniqueKeyField,
    Field, SortedField, GeoField, Relationship, DatetimeField,
)

class Restaurant(Model):
    name = KeyField()
    cuisine = Field(type=str)
    rating = SortedField(type=float)
    location = GeoField()

class MenuItem(Model):
    item_id = AutoKeyField()
    name = Field(type=str)
    price = SortedField(type=float)
    restaurant = Relationship(model=Restaurant)  # links to Restaurant
    available = Field(type=bool, default=True)

class Customer(Model):
    username = KeyField()
    email = UniqueKeyField()
    name = Field(type=str)
    address = GeoField()

class Driver(Model):
    driver_id = AutoKeyField()
    name = Field(type=str)
    phone = UniqueKeyField()
    rating = SortedField(type=float)
    location = GeoField()

class Order(Model):
    order_id = AutoKeyField()
    customer = Relationship(model=Customer)
    restaurant = Relationship(model=Restaurant)
    driver = Relationship(model=Driver, null=True)  # nullable
    total = SortedField(type=float)
    status = Field(type=str, default="pending")
    created_at = DatetimeField(auto_now_add=True)
    updated_at = DatetimeField(auto_now=True)

    class Meta:
        order_by = "-created_at"
        ttl = 2592000  # 30 days

MenuItem.restaurant is a required relationship. Order has three: customer and restaurant are required, while driver is nullable because a driver is assigned only after the order is accepted.

Create the referenced model first, then pass it to the relationship field.

burger_palace = Restaurant.create(
    name="Burger Palace", cuisine="American",
    rating=4.5, location=(40.7128, -74.0060),
)

classic_burger = MenuItem.create(
    name="Classic Burger", price=12.99, restaurant=burger_palace,
)

print(classic_burger.restaurant.name)
# => "Burger Palace"

For an Order, pass multiple related instances at once.

alice = Customer.create(username="alice", email="alice@example.com",
                        name="Alice Chen", address=(40.7128, -74.0060))

order = Order.create(customer=alice, restaurant=burger_palace,
                     total=25.98, status="pending")
print(order.customer.name)
# => "Alice Chen"
print(order.restaurant.name)
# => "Burger Palace"

You can reassign a relationship and call save() to update it.

sushi_house = Restaurant.create(name="Sushi House", cuisine="Japanese",
                                rating=4.8, location=(40.7580, -73.9855))
classic_burger.restaurant = sushi_house
classic_burger.save()
print(classic_burger.restaurant.cuisine)
# => "Japanese"

Querying by Relationship

Pass a model instance to filter for all objects that reference it.

items = MenuItem.query.filter(restaurant=burger_palace)
for item in items:
    print(f"{item.name} - ${item.price}")
# => "Classic Burger - $12.99"

alice_orders = Order.query.filter(customer=alice)
print(len(alice_orders))
# => 1

Note

The filter value must be a model instance, not a string or key value. Order.query.filter(customer=alice) works, but Order.query.filter(customer="alice") raises a QueryException.

Nested Field Access

Use double-underscore notation to query by fields on the related model.

orders = Order.query.filter(restaurant__name="Burger Palace")
for order in orders:
    print(f"Order {order.order_id}: ${order.total}")
# => "Order ...: $25.98"

# Combine nested filters with other parameters
big_orders = Order.query.filter(
    restaurant__name="Burger Palace", total__gte=20.0,
)
print(len(big_orders))
# => 1

See Making Queries for the full list of filter operators.

Warning

Nested filters do not recurse through multiple relationship levels. Order.query.filter(restaurant__name="Burger Palace") works because name is a direct field on Restaurant. You cannot chain through a second relationship.

Traversing Relationships

Because Redis has no JOINs, you traverse relationships with Python loops.

alice = Customer.query.get(username="alice")
alice_orders = Order.query.filter(customer=alice)
restaurants = [order.restaurant for order in alice_orders]
for r in restaurants:
    print(r.name)
# => "Burger Palace"

N+1 Query Problem

Each order.restaurant access triggers a separate Redis call. With 100 orders, that is 100 additional round trips on top of the initial query.

# Anti-pattern: 1 query + N lazy loads
for order in Order.query.all():
    print(order.customer.name)   # Redis GET per iteration
    print(order.restaurant.name) # Another Redis GET per iteration

For large result sets, consider denormalizing frequently accessed fields (e.g., storing restaurant_name directly on Order).

How Relationships Work Internally

Storage as Redis Key

Popoto stores the related instance's Redis key as a string, not the full serialized object. A MenuItem with restaurant=burger_palace stores "Restaurant:Burger Palace" in the Redis hash.

Lazy Loading

When you load a model, relationship fields initially hold the raw Redis key string. Popoto resolves it to a full instance only when you access the field.

item = MenuItem.query.get(name="Fries")
# item.restaurant is internally "Restaurant:Burger Palace"
restaurant = item.restaurant  # triggers Redis HGETALL
print(restaurant.cuisine)     # => "American"
# Subsequent access uses the resolved instance -- no extra Redis call

Because the stored value is a key string rather than an embedded object, an import that regenerates keys can repoint a relationship by rewriting that string, without ever loading the target. That is what Relationship.remap_references does under import_records(..., preserve_keys=False) and popoto-transfer import --regenerate-keys; a target that is not part of the same import keeps its old key and is reported as dangling. See Regenerating keys on import.

Relationship Index

Popoto maintains a Redis set for each relationship value so that filter(restaurant=instance) is an O(1) set lookup rather than a scan.

$RelationshipF:MenuItem:restaurant:Restaurant:Burger Palace
  -> { MenuItem:<key1>, MenuItem:<key2>, ... }

Indexes are created on save(), updated when the relationship changes, and cleaned up on delete(). When you reassign a relationship (e.g., moving a MenuItem from one Restaurant to another), save() removes the instance from the old relationship's index set and adds it to the new one. This ensures that filter(restaurant=old_restaurant) no longer returns the moved item.

filter(restaurant=instance) reads that index set in full (SMEMBERS) and hydrates every match. On a hub node — a restaurant with 50,000 menu items, a memory that half the graph points at — that is exactly the unbounded fan-out you do not want in a loop that is walking edges.

Relationship.sample_related_keys() is the bounded read of the same set. It issues one SRANDMEMBER and returns the raw member keys as strings, without loading a single instance:

from popoto import Relationship

# Up to 20 MenuItem keys pointing at this restaurant, sampled at random.
keys = Relationship.sample_related_keys(
    MenuItem, "restaurant", burger_palace.db_key, 20
)
# => ["MenuItem:classic-burger", "MenuItem:fries", ...]
Argument Meaning
model The model class holding the Relationship field (MenuItem, not Restaurant).
field_name The name of the field on that model.
related_key The key being pointed at. A DB_key is used as-is; a str is treated as a full Redis key and parsed with DB_key.from_redis_key().
count Passed straight to SRANDMEMBER.

Three things to know:

  • The sample is random and unordered. SRANDMEMBER with a positive count returns distinct members with no ordering guarantee, and two calls with the same arguments will generally return different members. It answers "give me some neighbors", not "give me the first N neighbors".
  • A str related_key is a Redis key, not a value to escape. Pass "Restaurant:Burger Palace", not "Burger Palace". Constructing DB_key("Restaurant:Burger Palace") would escape the colon and address a key that does not exist, so the method parses rather than constructs.
  • It does not catch anything. A connection failure propagates. Callers that want to degrade — continue the walk with no neighbors rather than abort — own that policy and catch around the call. popoto.recipes.graph_traversal does exactly this.

An unknown or never-saved related_key has no index set, so the method returns [] rather than raising.

Circular Reference Protection

Popoto tracks which instances are currently being deserialized. If it encounters a cycle, it leaves the relationship as a Redis key string instead of recursing infinitely.

Null Relationships

An Order starts without a driver -- None until one is assigned.

order = Order.create(customer=alice, restaurant=burger_palace,
                     total=15.50, status="pending")
print(order.driver)
# => None

dave = Driver.create(name="Dave", phone="555-0199",
                     rating=4.9, location=(40.7300, -73.9950))
order.driver = dave
order.status = "assigned"
order.save()
print(order.driver.name)
# => "Dave"

Tip

Always check for None before accessing attributes on a nullable relationship to avoid AttributeError.

if order.driver is not None:
    print(order.driver.name)
else:
    print("No driver assigned yet")

Self-Referential Relationships

A model can reference itself -- useful for combo meals that include other menu items.

class MenuItem(Model):
    item_id = AutoKeyField()
    name = Field(type=str)
    price = SortedField(type=float)
    restaurant = Relationship(model=Restaurant)
    combo_includes = Relationship(model='MenuItem', null=True)

burger = MenuItem.create(name="Classic Burger", price=12.99,
                         restaurant=burger_palace, combo_includes=None)
combo = MenuItem.create(name="Burger Combo", price=14.99,
                        restaurant=burger_palace, combo_includes=burger)
print(combo.combo_includes.name)
# => "Classic Burger"

Use a string ('MenuItem') when the class is not yet fully defined. Set null=True so non-combo items can leave the field empty.

Best Practices

Query from the Model That Holds the Relationship

You can only filter on a relationship from the model that declares it.

orders = Order.query.filter(customer=alice)       # Order has customer field
# Customer.query.filter(orders=...) is NOT possible

If you need reverse lookups, add a relationship field in both directions.

Denormalize for Frequent Access Patterns

When you repeatedly need a related field's value, store it directly on the model. For example, adding restaurant_name to Order avoids lazy-loading the Restaurant just to display its name.

Tip

Denormalization is a common Redis pattern. Duplicating a field is often cheaper than chaining lookups, but you must keep it in sync manually.

No Cascade Delete

Deleting a model does not cascade to related instances. If you delete a Restaurant, any MenuItem or Order referencing it will hold a stale key.

Warning

Always delete or reassign dependent instances before removing the referenced model.

for item in MenuItem.query.filter(restaurant=burger_palace):
    item.delete()
burger_palace.delete()

Keep Relationship Depth Shallow

Each relationship traversal costs a Redis round trip. Design your models so the most common access patterns require at most one or two hops.

See Making Queries for filter operators and Model Meta Options for order_by and ttl configuration.