Making Queries¶
Every Popoto model has a query attribute that provides a Django-like interface for retrieving
instances from Redis. You can look up a single object by its key, fetch all instances, filter by
field values, count results, and return lightweight dictionaries instead of full model objects.
Queries combine multiple filters with AND logic. If you pass several filter parameters, only instances matching all criteria are returned.
from popoto import (
Model, KeyField, AutoKeyField, UniqueKeyField,
Field, SortedField, GeoField, Relationship,
)
from popoto.fields.datetime_field import DatetimeField
class Restaurant(Model):
name = KeyField()
cuisine = Field(type=str)
rating = SortedField(type=float)
location = GeoField()
active = Field(type=bool, default=True)
class MenuItem(Model):
item_id = AutoKeyField()
name = Field(type=str)
price = SortedField(type=float)
restaurant = Relationship(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()
active = Field(type=bool, default=True)
class Order(Model):
order_id = AutoKeyField()
customer = Relationship(Customer)
restaurant = Relationship(Restaurant)
driver = Relationship(Driver, null=True)
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
These models are used throughout the examples on this page. See Models and Fields for full details on each field type.
Get a Single Object¶
When you know the exact key values, use query.get() to retrieve one instance. This performs a
direct Redis key lookup, making it the fastest way to load a model.
burger_palace = Restaurant.create(
name="Burger Palace",
cuisine="American",
rating=4.5,
location=(40.7128, -74.0060),
)
restaurant = Restaurant.query.get(name="Burger Palace")
print(restaurant.cuisine)
# => "American"
You can also retrieve an object by its raw Redis key.
restaurant = Restaurant.query.get(redis_key="Restaurant:Burger Palace")
print(restaurant.rating)
# => 4.5
If no matching instance exists, get() returns None. If more than one instance matches
(possible when filtering on non-key fields), it raises a QueryException.
Note
For models with composite keys, pass all key field values to get(). For models with
AutoKeyField, use the auto-generated key value or redis_key.
Get Multiple Objects by Key¶
When you already have a list of Redis keys (for example, from a set intersection, a cache of
"recently viewed" items, or a secondary index), use query.get_many() to hydrate them all in
a single round-trip. Internally this uses a Redis pipeline to batch HGETALL calls, turning
N sequential lookups into one network exchange.
keys = ["Restaurant:Burger Palace", "Restaurant:Sushi Zen", "Restaurant:Gone Place"]
restaurants = Restaurant.query.get_many(redis_keys=keys)
print(restaurants)
# => [<Restaurant>, <Restaurant>, None] -- third key did not exist
The returned list preserves the same order as the input keys. Missing keys appear as None
so you can correlate each position with the original key list.
If you do not need the None placeholders, pass skip_none=True to drop them.
restaurants = Restaurant.query.get_many(redis_keys=keys, skip_none=True)
print(restaurants)
# => [<Restaurant>, <Restaurant>] -- only existing objects
An empty input list returns an empty list immediately, without touching Redis.
Tip
get_many() is ideal for the "fan-out then hydrate" pattern: use query.keys() or a
Redis set operation to collect keys, then hydrate them in bulk. This avoids the overhead
of loading and filtering all instances when you already know which keys you want.
Note
get_many() differs from the internal get_many_objects() method. The public method
takes a list of string keys, preserves input order, and returns None for missing entries.
The internal method takes a set of bytes keys and silently drops missing entries.
Live demo
The Popoto Kitchen example app
demonstrates get_many() in its operations script. Run python -m popoto_kitchen --ops
to see bulk Order loading with both default and skip_none=True modes. Source:
examples/popoto_kitchen/operations.py.
Async get_many()¶
The async counterpart async_get_many() uses a native async Redis pipeline, so it does
not block the event loop even when hydrating hundreds of keys. It accepts the same
parameters and preserves the same ordering guarantees as the synchronous version.
async def bulk_lookup():
keys = ["Restaurant:Burger Palace", "Restaurant:Sushi Zen", "Restaurant:Gone Place"]
restaurants = await Restaurant.query.async_get_many(redis_keys=keys)
# => [<Restaurant>, <Restaurant>, None]
# Drop missing entries
restaurants = await Restaurant.query.async_get_many(redis_keys=keys, skip_none=True)
# => [<Restaurant>, <Restaurant>]
This pairs naturally with async_keys() for the "fan-out then hydrate" pattern:
async def hydrate_all():
keys = await Restaurant.query.async_keys()
restaurants = await Restaurant.query.async_get_many(redis_keys=keys, skip_none=True)
print(f"Loaded {len(restaurants)} restaurants")
Tip
async_get_many() is especially useful inside asyncio.gather() when you need to
hydrate keys from multiple models concurrently. See Async Operations
for more examples.
Get All Objects¶
Use query.all() to retrieve every instance of a model. This fetches all Redis keys registered
to that model and loads each one.
The all() method accepts the same order_by, limit, and values parameters as filter().
# All restaurants ordered by name
restaurants = Restaurant.query.all(order_by="name")
# First 5 restaurants
restaurants = Restaurant.query.all(limit=5)
# All restaurants as dicts with only the name field
restaurants = Restaurant.query.all(values=("name",))
# => [{"name": "Burger Palace"}]
Tip
If a model defines order_by in its Meta class, all() uses that ordering by default.
The Order model above defaults to "-created_at" (newest first). You can override this
at query time by passing a different order_by value.
Filter Query Results¶
Use query.filter() to retrieve instances matching specific criteria. Filters are expressed as
keyword arguments using Django-style lookup expressions.
sushi_zen = Restaurant.create(
name="Sushi Zen",
cuisine="Japanese",
rating=4.8,
location=(40.7580, -73.9855),
)
japanese = Restaurant.query.filter(name__contains="Sushi")
print(len(japanese))
# => 1
print(japanese[0].name)
# => "Sushi Zen"
You can combine multiple filters. Only instances matching all criteria are returned.
Note
All filter parameters are AND-ed together by default. For OR logic, use Q objects described in the Q Objects section below.
Chainable Query Builder¶
Popoto supports two query syntaxes: the traditional kwargs-based API shown above, and a fluent chainable API that allows method chaining. Both syntaxes are fully supported and can be used interchangeably based on your preference.
Chainable vs Kwargs Syntax¶
The original kwargs API passes all parameters to a single filter() call.
# Kwargs API - all parameters in one call
results = Restaurant.query.filter(
rating__gte=4.0,
order_by="-rating",
limit=10
)
The chainable API builds queries incrementally with method chaining.
# Chainable API - fluent method chaining
results = Restaurant.query.filter(rating__gte=4.0).order_by("-rating").limit(10).all()
Both approaches produce identical results. The chainable API can be more readable when building complex queries dynamically or when you prefer a fluent interface style.
Chainable Methods¶
The QueryBuilder returned by filter() supports these chainable methods:
| Method | Description |
|---|---|
filter(**kwargs) |
Add filter criteria, returns new QueryBuilder |
order_by(field) |
Set sort field (prefix with "-" for descending) |
limit(n) |
Set maximum number of results |
values(*fields) |
Return dicts with specified fields instead of model instances |
computed_sort(fn, reverse) |
Sort by a Python key function (applied after fetch, before limit) |
no_track() |
Suppress on_read() tracking for AccessTrackerMixin models |
top_by_decay(field_name, n) |
Return top-N by time-decayed score (API ref) |
composite_score(indexes, limit, temperature) |
Return top-K by weighted composite of multiple sorted indexes (API ref) |
semantic_search(query_text, indexes, limit) |
Return top-K by semantic similarity, optionally combined with sorted indexes (API ref) |
keyword_search(query_text, field, limit) |
Return instances ranked by BM25 keyword relevance. See Hybrid Retrieval. |
fuse(k, limit, post_filter, **ranked_lists) |
Reciprocal Rank Fusion across heterogeneous ranked lists. See Hybrid Retrieval. |
all() |
Execute query and return all results as a list |
first() |
Execute query and return first result or None |
count() |
Count matching results without loading objects |
Chaining Multiple Filters¶
You can chain multiple filter() calls to incrementally add criteria. Each call returns a new
QueryBuilder with the combined filters.
# Chain multiple filters - equivalent to AND-ing criteria
results = (
Restaurant.query
.filter(rating__gte=4.0)
.filter(cuisine="Japanese")
.order_by("-rating")
.limit(5)
.all()
)
This is equivalent to the kwargs version.
# Same query using kwargs API
results = Restaurant.query.filter(
rating__gte=4.0,
cuisine="Japanese",
order_by="-rating",
limit=5
)
Getting the First Result¶
Use first() to retrieve only the first matching result, or None if no matches exist.
This is more efficient than calling all()[0] when you only need one object.
# Get the highest-rated Japanese restaurant
top_japanese = (
Restaurant.query
.filter(cuisine="Japanese")
.order_by("-rating")
.first()
)
if top_japanese:
print(f"Best Japanese: {top_japanese.name} ({top_japanese.rating})")
Counting Without Loading¶
Use count() on a QueryBuilder to count matching results without loading objects into memory.
# Count premium menu items without loading them
expensive_count = MenuItem.query.filter(price__gte=20.0).count()
print(f"Premium items: {expensive_count}")
Using Values with Chainable API¶
The values() method specifies which fields to return as dictionaries.
# Get only names and ratings as dicts
restaurant_data = (
Restaurant.query
.filter(rating__gte=4.0)
.values("name", "rating")
.order_by("-rating")
.all()
)
# => [{"name": "Sushi Zen", "rating": 4.8}, {"name": "Burger Palace", "rating": 4.5}]
Sorting by Computed Values¶
Use computed_sort() to sort results by a value computed in Python rather than a stored
field. The sort is applied after fetching results from Redis but before limit(), so you
get the correct top-N results.
# Sort restaurants by a weighted score combining rating and review count
results = (
Restaurant.query
.filter(active=True)
.computed_sort(lambda r: r.rating * 0.7 + r.review_count * 0.3, reverse=True)
.limit(10)
.all()
)
The fn argument is any callable that takes a model instance (or dict when using
values()) and returns a sort key. Set reverse=True for descending order.
# Works with values() projection -- fn receives dicts
data = (
MenuItem.query
.filter(available=True)
.computed_sort(lambda d: d["price"] / d["rating"], reverse=False)
.values("name", "price", "rating")
.limit(5)
.all()
)
When both computed_sort() and order_by() are set, computed_sort() takes precedence
and order_by() is ignored (with a warning logged).
Performance
computed_sort() applies a Python sorted() call (O(N log N)) on the full result
set before slicing with limit(). For large result sets (>10K records), prefer
SortedField indexes which leverage Redis sorted sets for O(log N + M) performance.
Backward Compatibility¶
The QueryBuilder is fully backward compatible with code that treats filter() results as a list.
You can iterate over a QueryBuilder directly, use len(), or access items by index.
# These all work - QueryBuilder acts like a list
results = Restaurant.query.filter(rating__gte=4.0)
# Iteration
for restaurant in results:
print(restaurant.name)
# Length
print(len(results))
# Indexing
first = results[0]
# Boolean check
if results:
print("Found matches!")
# Comparison with list
assert results == Restaurant.query.filter(rating__gte=4.0).all()
Each of these operations executes the query when first accessed. For repeated access, call
all() once and store the result.
# More efficient for multiple operations
results = Restaurant.query.filter(rating__gte=4.0).all()
print(len(results))
for r in results:
print(r.name)
Tip
The chainable API is especially useful when building queries conditionally. You can
start with a base query and add filters based on runtime conditions, then call all()
at the end.
# Build query conditionally
query = Restaurant.query.filter(active=True)
if min_rating:
query = query.filter(rating__gte=min_rating)
if cuisine_type:
query = query.filter(cuisine=cuisine_type)
results = query.order_by("-rating").limit(20).all()
Q Objects (OR, AND, NOT)¶
Q objects enable complex query logic with OR, AND, and NOT operators. They provide a Django-style interface for building expressive filter expressions that go beyond simple AND-ed kwargs.
Importing Q¶
Import the Q class from popoto alongside your other imports.
Basic Q Object Usage¶
A Q object wraps filter kwargs, which can then be combined with other Q objects using operators.
# Simple Q object - equivalent to filter(status="active")
active = Restaurant.query.filter(Q(active=True))
# Q objects support all the same lookups as regular filter kwargs
high_rated = Restaurant.query.filter(Q(rating__gte=4.5))
OR Queries with |¶
Use the | (pipe) operator to combine Q objects with OR logic. This returns instances matching
either condition.
# Find restaurants that are Japanese OR have a high rating
results = Restaurant.query.filter(
Q(cuisine="Japanese") | Q(rating__gte=4.5)
)
You can chain multiple OR conditions.
# Find restaurants matching any of three cuisines
results = Restaurant.query.filter(
Q(cuisine="Japanese") | Q(cuisine="Italian") | Q(cuisine="Mexican")
)
Tip
For simple "match any of these values" queries, the __in lookup is more efficient than
multiple OR conditions: filter(cuisine__in=["Japanese", "Italian", "Mexican"]).
AND Queries with &¶
Use the & operator for explicit AND logic. This returns instances matching both conditions.
# Find active restaurants with high ratings
results = Restaurant.query.filter(
Q(active=True) & Q(rating__gte=4.0)
)
Note
Explicit AND with & is equivalent to passing multiple kwargs to filter(). Both of
these queries produce identical results:
# Using Q with &
Restaurant.query.filter(Q(active=True) & Q(rating__gte=4.0))
# Using kwargs (simpler)
Restaurant.query.filter(active=True, rating__gte=4.0)
& when you need to combine it with OR logic in the same expression.
Negation with ~¶
Use the ~ (tilde) operator to negate a Q object. This returns instances that do NOT match the
condition.
# Find all restaurants that are NOT inactive
results = Restaurant.query.filter(~Q(active=False))
# Find restaurants not in the "Fast Food" cuisine
results = Restaurant.query.filter(~Q(cuisine="Fast Food"))
Warning
Negation (~Q) requires scanning all keys for the model to compute the set difference.
On large datasets with many instances, this can be slow and memory-intensive. Use with
caution in production, and prefer positive filters when possible.
Complex Nested Expressions¶
Q objects can be nested to build arbitrarily complex expressions. Use parentheses to control operator precedence.
# Active OR premium restaurants, with high ratings
results = Restaurant.query.filter(
(Q(active=True) | Q(cuisine="Premium")) & Q(rating__gte=4.0)
)
# Complex: (active AND high-rated) OR (premium AND any rating)
results = Restaurant.query.filter(
(Q(active=True) & Q(rating__gte=4.5)) | Q(cuisine="Premium")
)
# Exclude specific combinations
results = Restaurant.query.filter(
Q(rating__gte=3.0) & ~Q(cuisine="Fast Food")
)
Mixing Q Objects with Kwargs¶
You can pass Q objects alongside regular kwargs to filter(). The Q objects are evaluated first,
then AND-ed with the kwargs filters.
# Q object OR combined with kwarg filter
# Equivalent to: (cuisine="Japanese" OR cuisine="Italian") AND active=True
results = Restaurant.query.filter(
Q(cuisine="Japanese") | Q(cuisine="Italian"),
active=True
)
This is useful when you have a dynamic OR condition but always want to apply a static filter.
# Dynamic cuisine filter with static active=True
cuisines = ["Japanese", "Italian", "Mexican"]
cuisine_q = Q(cuisine=cuisines[0])
for c in cuisines[1:]:
cuisine_q = cuisine_q | Q(cuisine=c)
results = Restaurant.query.filter(cuisine_q, active=True)
Q Objects with Chainable API¶
Q objects work seamlessly with the chainable query builder.
results = (
Restaurant.query
.filter(Q(cuisine="Japanese") | Q(cuisine="Italian"))
.filter(rating__gte=4.0)
.order_by("-rating")
.limit(10)
.all()
)
Supported Lookups in Q Objects¶
Q objects support all the same lookups as regular filter kwargs:
| Field Type | Supported Lookups |
|---|---|
| KeyField | =, __in, __contains, __startswith, __endswith, __isnull |
| IndexedField / UniqueField | =, __in, __startswith, __endswith, __isnull |
| SortedField | =, __gt, __gte, __lt, __lte |
| GeoField | location=, location_radius=, etc. |
| Field | = (exact match) |
# Q with SortedField range
Restaurant.query.filter(Q(rating__gte=4.0) | Q(rating__lte=2.0))
# Q with KeyField pattern matching
Restaurant.query.filter(Q(name__startswith="A") | Q(name__startswith="B"))
Performance Considerations¶
Q objects provide expressive query syntax, but understanding their performance characteristics helps you write efficient code.
OR queries execute both sides: When you use |, Popoto evaluates both Q objects and computes
the union of their result sets. This means two separate filter operations are performed.
# This executes TWO filter operations, then unions the results
Restaurant.query.filter(Q(cuisine="Japanese") | Q(cuisine="Italian"))
# This is more efficient for simple value matching
Restaurant.query.filter(cuisine__in=["Japanese", "Italian"])
AND queries use set intersection: When you use &, Popoto evaluates both Q objects and
computes the intersection. This is similar to passing multiple kwargs.
Negation scans all keys: The ~ operator requires fetching all keys for the model to
compute the set difference. Avoid ~Q on models with many instances.
| Expression | Redis Operations |
|---|---|
Q(a=1) |
Single filter |
Q(a=1) \| Q(b=2) |
Two filters + set union |
Q(a=1) & Q(b=2) |
Two filters + set intersection |
~Q(a=1) |
One filter + all keys scan + set difference |
Tip
For best performance, prefer __in over multiple OR conditions when matching against a
list of values, and avoid ~Q on large datasets.
Expression-Based Queries¶
Expression-based queries let you use Python comparison operators directly on Field classes to build filter conditions. This provides a more Pythonic, type-safe way to write queries with excellent IDE support for autocomplete and error detection.
Expression vs Kwargs Syntax¶
The traditional kwargs syntax uses field names as strings with lookup suffixes.
The expression syntax uses Python operators on Field classes accessed through the Model.
# Expression syntax
Restaurant.query.filter(Restaurant.rating >= 4.0, Restaurant.status == "active")
Both approaches produce identical results. The expression syntax offers IDE autocomplete on field names, compile-time detection of typos, and a more natural Python feel.
Supported Operators¶
Expression-based queries support all standard Python comparison operators.
| Operator | Description | Equivalent Lookup |
|---|---|---|
== |
Equal to | field=value |
!= |
Not equal to | ~Q(field=value) |
> |
Greater than | field__gt=value |
>= |
Greater than or equal | field__gte=value |
< |
Less than | field__lt=value |
<= |
Less than or equal | field__lte=value |
Here are examples using different operators.
# Equal to
Restaurant.query.filter(Restaurant.name == "Burger Palace")
# Not equal to
Restaurant.query.filter(Restaurant.status != "closed")
# Greater than
MenuItem.query.filter(MenuItem.price > 20.0)
# Greater than or equal
Restaurant.query.filter(Restaurant.rating >= 4.5)
# Less than
MenuItem.query.filter(MenuItem.price < 10.0)
# Less than or equal
Order.query.filter(Order.total <= 50.0)
Combining Expressions with & and |¶
Use the & operator for AND logic and | for OR logic, just like with Q objects. Parentheses
control operator precedence.
# AND: restaurants with high rating AND active status
Restaurant.query.filter(
(Restaurant.rating >= 4.0) & (Restaurant.active == True)
)
# OR: restaurants that are Japanese OR Italian
Restaurant.query.filter(
(Restaurant.cuisine == "Japanese") | (Restaurant.cuisine == "Italian")
)
# Complex: (high-rated AND active) OR premium cuisine
Restaurant.query.filter(
((Restaurant.rating >= 4.5) & (Restaurant.active == True))
| (Restaurant.cuisine == "Premium")
)
Note
Always use parentheses around individual expressions when combining with & or |.
Without parentheses, Python's operator precedence may produce unexpected results.
Mixing Expressions with Kwargs¶
You can mix expression-based filters with traditional kwargs in the same filter() call. This is
useful when some conditions are more naturally expressed one way or the other.
# Expression for comparison, kwarg for exact match
Restaurant.query.filter(
Restaurant.rating > 4.0,
active=True
)
# Multiple expressions with kwargs
MenuItem.query.filter(
MenuItem.price >= 5.0,
MenuItem.price <= 15.0,
available=True
)
The expressions and kwargs are combined with AND logic, just like multiple kwargs would be.
Expressions with Chainable API¶
Expression-based queries work seamlessly with the chainable query builder.
# Chain expressions with other query methods
results = (
Restaurant.query
.filter(Restaurant.rating >= 4.0)
.filter(Restaurant.active == True)
.order_by("-rating")
.limit(10)
.all()
)
# Mix expressions and kwargs in chained calls
results = (
MenuItem.query
.filter(MenuItem.price <= 20.0)
.filter(available=True)
.order_by("price")
.first()
)
IDE Autocomplete Benefits¶
One of the main advantages of expression-based queries is improved developer experience with IDE support.
Autocomplete: When you type Restaurant., your IDE suggests all available fields (name,
cuisine, rating, location, active). No need to remember field names or check the model definition.
Type checking: If you mistype a field name, your IDE and type checker can catch it immediately.
With kwargs, a typo like ratng__gte=4.0 would silently fail or raise an error at runtime.
Refactoring: When you rename a field, IDE refactoring tools can update all expression-based queries automatically. String-based kwargs require manual find-and-replace.
# IDE catches this typo immediately
Restaurant.query.filter(Restaurant.ratng >= 4.0) # AttributeError highlighted
# This typo is only caught at runtime
Restaurant.query.filter(ratng__gte=4.0) # No IDE warning
When to Use Expressions vs Kwargs¶
Both syntaxes are fully supported. Choose based on your preference and use case.
Use expressions when:
- You want IDE autocomplete and type checking
- Building queries with comparison operators (
>,<,>=,<=) - Combining conditions with
&and|operators - Working in a codebase that values static analysis
Use kwargs when:
- Writing simple exact-match filters
- Using special lookups like
__contains,__startswith,__in - Dynamically building queries from dictionaries
- Maintaining consistency with existing Django-style code
# Expressions are natural for comparisons
Restaurant.query.filter(Restaurant.rating >= 4.0)
# Kwargs are concise for pattern matching
Restaurant.query.filter(name__startswith="B")
# Mix both as needed
Restaurant.query.filter(
Restaurant.rating >= 4.0,
name__contains="Sushi"
)
Count Results¶
Use query.count() to get the number of matching instances without loading them into memory.
This is more efficient than calling len() on a full result set.
total_restaurants = Restaurant.query.count()
print(total_restaurants)
# => 2
# Count with filters
top_count = MenuItem.query.count(price__lte=15.0)
When called without arguments, count() uses Redis SCARD for a fast cardinality check. With
filter arguments, it computes the intersection of matching key sets.
Get Redis Keys¶
Use query.keys() to retrieve the raw Redis keys for all instances of a model. This is useful
for debugging or performing custom Redis operations.
Warning
The keys() method returns bytes objects. The clean=True parameter is deprecated.
Use Model.clean_indexes() for production-safe
orphan removal that covers all five index types.
Values¶
Return results as dictionaries instead of model instances by passing a values tuple. Specify
which fields to include. This is faster and uses less memory when you only need a few fields.
names_and_cuisines = Restaurant.query.all(values=("name", "cuisine"))
# => [{"name": "Burger Palace", "cuisine": "American"}, ...]
You can combine values with filter() for targeted queries.
cheap_items = MenuItem.query.filter(
price__lte=10.0,
values=("name", "price"),
)
# => [{"name": "Fries", "price": 4.99}, ...]
Tip
When all fields in values are key fields, Popoto extracts values directly from the Redis
key string without deserializing stored data. This can be 2x faster or more for large
result sets.
Order By¶
Use order_by to sort results by any field. Prefix the field name with - for descending
order. Ascending order is the default.
# Cheapest menu items first
items = MenuItem.query.all(order_by="price")
# Highest-rated restaurants first
restaurants = Restaurant.query.all(order_by="-rating")
Ordering works with filter() as well.
You can set a default ordering in the model's Meta class. The Order model above uses
order_by = "-created_at" so that queries return the most recent orders first. You can
override this at query time.
# Uses Meta default: newest orders first
recent_orders = Order.query.all()
# Override: sort by total ascending
orders_by_total = Order.query.all(order_by="total")
See Model Meta Options for more on configuring default ordering.
Note
The order_by field must be included in values if you are using both parameters
together. Otherwise Popoto raises a QueryException.
Limit Results¶
Use limit to cap the number of returned instances. The limit is applied after filtering and
ordering.
You can also use Python slice notation on the result list.
Both approaches return the same results. The explicit limit parameter may be slightly more
efficient because it can short-circuit internal sorting. Combine limit with order_by to
get "top N" style queries.
KeyField Filters¶
KeyField filters support string matching operations on the values that make up the Redis key. All lookups are case-sensitive.
| Lookup | Description |
|---|---|
name= |
Exact match |
name__isnull= |
Filter for null (True) or non-null (False) values |
name__contains= |
Substring match anywhere in the value |
name__startswith= |
Prefix match |
name__endswith= |
Suffix match |
name__in= |
Match any value in the provided list |
Here are examples using the Restaurant model's name KeyField.
# Exact match
Restaurant.query.filter(name="Burger Palace")
# Restaurants whose name contains "Sushi"
Restaurant.query.filter(name__contains="Sushi")
# Restaurants whose name starts with "B"
Restaurant.query.filter(name__startswith="B")
# Restaurants whose name ends with "Zen"
Restaurant.query.filter(name__endswith="Zen")
# Match any of several names
Restaurant.query.filter(name__in=["Burger Palace", "Sushi Zen", "Taco Town"])
# All restaurants that have a name set (non-null)
Restaurant.query.filter(name__isnull=False)
Note
KeyField lookups like __contains, __startswith, and __endswith scan all keys for
the model and filter in Python. For large datasets, prefer exact match or __in lookups
which use Redis set operations directly.
IndexedField Filters¶
IndexedField and UniqueField filters use Redis Set operations for efficient exact-match queries
on non-key fields. They support the same lookups as KeyField (except __contains).
| Lookup | Description |
|---|---|
status= |
Exact match |
status__isnull= |
Filter for null (True) or non-null (False) values |
status__startswith= |
Prefix match |
status__endswith= |
Suffix match |
status__in= |
Match any value in the provided list |
Here are examples using an Order model with status = IndexedField(type=str).
from popoto import Model, AutoKeyField, IndexedField, SortedField
class Order(Model):
order_id = AutoKeyField()
status = IndexedField(type=str)
total = SortedField(type=float)
# Exact match
Order.query.filter(status="shipped")
# Match any of several statuses (server-side SUNION)
Order.query.filter(status__in=["pending", "processing"])
# Orders with a non-null status
Order.query.filter(status__isnull=False)
# Prefix match
Order.query.filter(status__startswith="ship")
# Combine with SortedField range queries
Order.query.filter(status="pending", total__gte=100.0)
Note
IndexedField __startswith and __endswith lookups use Redis SCAN, similar to KeyField
pattern queries. For large datasets, prefer exact match or __in lookups which use
direct Set operations.
SortedField Filters¶
SortedField filters use Redis sorted sets for efficient numeric and date/time range queries. These are the fastest way to filter on continuous values.
| Lookup | Description |
|---|---|
price= |
Exact match |
price__gt= |
Greater than |
price__gte= |
Greater than or equal to |
price__lt= |
Less than |
price__lte= |
Less than or equal to |
Filter menu items by price.
# Items under $10
budget_items = MenuItem.query.filter(price__lt=10.0)
# Items $10 or more
premium_items = MenuItem.query.filter(price__gte=10.0)
# Items between $5 and $15
mid_range = MenuItem.query.filter(price__gte=5.0, price__lte=15.0)
Filter restaurants by rating.
# Highly rated restaurants (4.0 and above)
top_rated = Restaurant.query.filter(rating__gte=4.0)
# Restaurants rated above 4.5
excellent = Restaurant.query.filter(rating__gt=4.5)
Combine range filters with other parameters for precise queries.
# Top-rated restaurants, highest first, limit to 10
best = Restaurant.query.filter(
rating__gte=4.0,
order_by="-rating",
limit=10,
)
Range queries work with any sortable type including int, float, Decimal, datetime,
date, and time. The Order model's total field is a good example.
# Orders over $50
big_orders = Order.query.filter(total__gt=50.0)
# Orders between $20 and $100
typical_orders = Order.query.filter(total__gte=20.0, total__lte=100.0)
Tip
SortedField range queries are backed by Redis ZRANGEBYSCORE, which runs in
O(log(N) + M) time where N is the total set size and M is the number of results. This
makes them efficient even on large datasets.
GeoField Filters¶
GeoField filters perform geographic proximity searches using Redis geospatial indexes. You can find all instances within a given radius of a point, specified either as coordinates or by referencing another model instance.
| Parameter | Description |
|---|---|
location= |
Tuple (latitude, longitude) or GeoField.Coordinates |
location_latitude= |
Float latitude value |
location_longitude= |
Float longitude value |
location_radius= |
Search radius distance (default: 10) |
location_radius_unit= |
"m", "km", "ft", or "mi" (default: "km") |
location_member= |
Use another instance's coordinates as center point |
location_with_distances= |
True to attach distance info to results |
Find restaurants near a delivery address.
# Restaurants within 5km of Times Square
nearby = Restaurant.query.filter(
location=(40.7580, -73.9855),
location_radius=5,
location_radius_unit='km',
)
for r in nearby:
print(r.name)
You can specify latitude and longitude as separate parameters.
nearby = Restaurant.query.filter(
location_latitude=40.7580,
location_longitude=-73.9855,
location_radius=3,
location_radius_unit='mi',
)
Use another model instance as the center point for the search. This is handy when you already have a loaded customer or driver and want to find nearby restaurants.
customer = Customer.create(
username="janedoe",
email="jane@example.com",
name="Jane Doe",
address=(40.7484, -73.9857),
)
# Restaurants within 2km of Jane's address
nearby = Restaurant.query.filter(
location_member=customer,
location_radius=2,
location_radius_unit='km',
)
Note
When using location_member, Popoto looks up the member's coordinates in the geo index.
The member must have been saved with a valid GeoField value. The GeoField name on the
member model must match the GeoField name used in the filter (e.g., both named location,
or use address for the customer model's field name accordingly).
Distances in Results¶
Pass location_with_distances=True to attach distance information to each returned instance.
Results are automatically sorted by distance, closest first.
results = Restaurant.query.filter(
location=(40.7128, -74.0060),
location_radius=10,
location_radius_unit='km',
location_with_distances=True,
)
for restaurant in results:
print(f"{restaurant.name}: {restaurant._geo_distance} "
f"{restaurant._geo_distance_unit}")
# => Burger Palace: 0.0 km
# => Sushi Zen: 5.2 km
Distance information is stored on each instance as _geo_distance (a float) and
_geo_distance_unit (a string matching the radius_unit you specified). This is useful for
displaying "2.3 km away" in a delivery app interface.
Finding Nearby Drivers¶
A common delivery-app pattern is finding the closest available driver to a restaurant.
driver = Driver.create(
name="Alex",
phone="555-0101",
rating=4.9,
location=(40.7120, -74.0050),
)
nearby_drivers = Driver.query.filter(
location=(40.7128, -74.0060),
location_radius=5,
location_radius_unit='km',
location_with_distances=True,
)
if nearby_drivers:
closest = nearby_drivers[0]
print(f"Assigning {closest.name} "
f"({closest._geo_distance} {closest._geo_distance_unit} away)")
# => Assigning Alex (0.1 km away)
See Models and Fields for more on defining GeoFields, and Relationship Field for linking drivers to orders.
Semantic Search¶
semantic_search() finds instances by meaning rather than exact field values. It embeds the
query text via the configured provider, computes cosine similarity against all stored embeddings,
and returns the top-K most similar instances.
This requires a model with an EmbeddingField and a configured embedding provider (via
popoto.configure() or per-field). See EmbeddingField for setup.
Basic Similarity Search¶
from popoto.fields.content_field import ContentField
from popoto.fields.embedding_field import EmbeddingField
class Memory(Model):
topic = KeyField()
content = ContentField()
relevance = SortedField(type=float)
embedding = EmbeddingField(source="content")
# Find memories semantically similar to a query
results = Memory.query.semantic_search("revenue trends", limit=5)
When called without indexes, results are ranked purely by cosine similarity.
Combined with Sorted Indexes¶
Pass indexes to blend semantic similarity with other sorted field scores using
composite_score() under the hood:
results = Memory.query.semantic_search(
"revenue trends",
indexes={"relevance": 0.4},
limit=10,
temperature=1.0,
)
The similarity scores are injected as an additional weighted signal alongside the sorted field indexes, producing a unified ranking.
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
query_text |
str |
(required) | The text to search for semantically. |
indexes |
dict |
None |
Mapping of sorted field names to weights for composite scoring. If None, ranks by similarity alone. |
limit |
int |
10 |
Maximum results to return. |
aggregate |
str |
"SUM" |
Aggregation mode for ZUNIONSTORE when using indexes. |
min_score |
float |
None |
Optional minimum composite score threshold. |
post_filter |
Callable |
None |
Optional (redis_key, score) -> bool filter. |
co_occurrence_boost |
dict |
None |
Optional {redis_key: weight} dict for association boosting. |
temperature |
float |
1.0 |
Score scaling factor. |
Returns an empty list if the query text is empty, no provider is configured, or no embeddings exist for the model.
Performance Best Practices¶
Popoto is optimized for common query patterns, but understanding the underlying Redis operations helps you write efficient code. Here are the key patterns to follow.
Use count() Instead of len(all())¶
The count() method uses Redis SCARD for O(1) cardinality checks when called without filters.
This is approximately 186x faster than loading all objects just to count them.
# Good: O(1) using Redis SCARD
total = Restaurant.query.count()
# Bad: Loads all objects into memory first
total = len(Restaurant.query.all())
Use values() for Partial Field Access¶
When you only need a few fields, use the values parameter. This is 2-4x faster than loading
full model instances because it uses HMGET instead of HGETALL and skips model instantiation.
# Good: Only fetches name and rating fields
data = Restaurant.query.all(values=("name", "rating"))
# => [{"name": "Burger Palace", "rating": 4.5}, ...]
# Better: If all fields are KeyFields, no Redis commands needed at all
names = Restaurant.query.all(values=("name",))
When all requested fields are KeyFields, Popoto extracts values directly from the Redis key strings without any additional Redis commands.
Prefer Exact Match and __in Over Pattern Filters¶
Exact match and __in lookups use Redis set operations (O(1) per value). Pattern filters
like __startswith, __endswith, and __contains use Redis SCAN which iterates through
keys.
# Good: Uses Redis set lookup - O(1)
Restaurant.query.filter(name="Burger Palace")
# Good: Uses SUNION for efficient OR queries
Restaurant.query.filter(name__in=["Burger Palace", "Sushi Zen"])
# Slower: Scans all keys matching pattern
Restaurant.query.filter(name__startswith="B")
Use SortedField for Range Queries¶
SortedField filters use Redis sorted sets with O(log N + M) time complexity where N is the index size and M is the result count. This is much faster than filtering in Python.
# Good: Uses Redis ZRANGEBYSCORE - O(log N + M)
MenuItem.query.filter(price__gte=5.0, price__lte=15.0)
# Avoid: Would require loading all items and filtering in Python
# (Popoto doesn't support this pattern, but the principle applies)
Limit Results When Possible¶
Apply limit to reduce the number of objects loaded from Redis. When combined with
order_by on a KeyField, the limit is applied before loading objects.
Lazy Loading for Bulk Queries¶
When iterating over query results, Popoto uses lazy deserialization by default. Field values are only decoded from msgpack when you access them. This significantly reduces overhead when you only use a subset of fields.
# Only decodes 'name' and 'rating' fields, not 'location' or 'cuisine'
for restaurant in Restaurant.query.all():
print(f"{restaurant.name}: {restaurant.rating}")
Query Performance Summary¶
| Operation | Redis Command | Time Complexity |
|---|---|---|
count() (no filters) |
SCARD | O(1) |
get(key=...) |
HGETALL | O(1) |
filter(field=...) |
SMEMBERS | O(1) |
filter(field__in=[...]) |
SUNION | O(N) where N = total members |
filter(indexed_field=...) |
SMEMBERS | O(1) |
filter(indexed_field__in=[...]) |
SUNION | O(N) where N = total members |
filter(field__startswith=...) |
SCAN | O(N) where N = total keys |
filter(sorted__gte=...) |
ZRANGEBYSCORE | O(log N + M) |
filter(geo=..., radius=...) |
GEORADIUS | O(N + log M) |
all() |
SMEMBERS + pipeline HGETALL | O(N) |
get_many(redis_keys=...) |
pipeline HGETALL | O(N) where N = number of keys |
composite_score(...) |
ZUNIONSTORE + ZREVRANGE + pipeline HGETALL | O(K log K + M) |
keyword_search(...) |
Lua BM25 scoring over inverted index | O(T * D) where T = query terms, D = docs per term |
fuse(...) |
Rank-merge + pipeline HGETALL | O(sum of list lengths + K log K) |
values(...) on KeyFields only |
No Redis call | O(1) |