Querying NoSQL Databases: Query Shape Follows the Data Model
There is no universal NoSQL query language. A document database, key-value store, wide-column database, and graph database expose different query shapes because they organize data differently.
This note uses four concrete technologies already covered elsewhere in the repository:
- MongoDB for documents,
- Redis for key/value and data structures,
- Cassandra for wide-column query-first tables,
- Neo4j for graphs.
The goal is not to memorize syntax. It is to see how the same business question changes when the data model changes.
One question, four models
Suppose a bookstore needs several operations:
- load a product and its descriptive fields,
- load a shopping cart by user ID,
- read one customer's recent events,
- traverse from a customer through followed customers to books they like.
Those operations naturally map to different query shapes.
document -> filter documents by fields
key-value -> compute key, then fetch/update value
wide-column -> supply partition key, then range within partition
graph -> match/traverse relationship pattern
A relational database may still solve all four well enough. Specialized systems are useful when the workload justifies them.
MongoDB: query documents by fields
The MongoDB examples use these products documents:
| _id | title | price_cents | tags | publisher.country |
|---|---|---|---|---|
| book-101 | Database Systems | 4990 | database, systems | DE |
| book-205 | SQL Practice | 3500 | database, tutorials | US |
The orders collection contains:
| _id | customer_id | status | placed_at | total_cents | items |
|---|---|---|---|---|---|
| order-1001 | customer-42 | paid | 2026-10-02 | 4990 | book-101 × 1 |
| order-1002 | customer-42 | open | 2026-10-03 | 3500 | book-205 × 1 |
| order-1003 | customer-77 | paid | 2026-10-01 | 2500 | book-205 × 1 |
Example product document:
{
"_id": "book-101",
"title": "Database Systems",
"price_cents": 4990,
"tags": ["database", "systems"],
"publisher": {
"name": "Example Press",
"country": "DE"
}
}
Exact lookup:
db.products.findOne({ _id: "book-101" })
Result:
| _id | title | price_cents | tags | publisher.country |
|---|---|---|---|---|
| book-101 | Database Systems | 4990 | database, systems | DE |
Filter by nested field:
db.products.find({
"publisher.country": "DE"
})
Result:
| _id | title |
|---|---|
| book-101 | Database Systems |
Array membership:
db.products.find({
tags: "database"
})
Result:
| _id | title |
|---|---|
| book-101 | Database Systems |
| book-205 | SQL Practice |
MongoDB query syntax expresses predicates over document fields and arrays.
Projection
Return only required fields:
db.products.find(
{ tags: "database" },
{ title: 1, price_cents: 1 }
)
Result:
| title | price_cents |
|---|---|
| Database Systems | 4990 |
| SQL Practice | 3500 |
Projection reduces network transfer and deserialization.
Comparison operators
db.products.find({
price_cents: { $gte: 3000, $lt: 6000 }
})
Result:
| _id | title | price_cents |
|---|---|---|
| book-101 | Database Systems | 4990 |
| book-205 | SQL Practice | 3500 |
Common operators include equality, range, membership, and logical combinations.
The exact index should follow the real filter/sort pattern.
Compound filters
db.orders.find({
customer_id: "customer-42",
status: "open",
placed_at: {
$gte: ISODate("2026-10-01T00:00:00Z")
}
})
Result:
| _id | customer_id | status | placed_at | total_cents |
|---|---|---|---|---|
| order-1002 | customer-42 | open | 2026-10-03 | 3500 |
This query suggests an index beginning with fields used for equality and range/sort according to the measured workload.
Sorting and limiting
db.orders.find({
customer_id: "customer-42"
})
.sort({ placed_at: -1 })
.limit(20)
Result, newest first:
| _id | placed_at | status |
|---|---|---|
| order-1002 | 2026-10-03 | open |
| order-1001 | 2026-10-02 | paid |
A compound index such as:
{ customer_id: 1, placed_at: -1 }
can support the access pattern.
Verify with explain().
Explain
db.orders.find({
customer_id: "customer-42"
})
.sort({ placed_at: -1 })
.explain("executionStats")
For the sample orders, the result summary is:
| metric | value |
|---|---|
| documents returned | 2 |
| keys examined | depends on indexes and plan |
| documents examined | depends on plan |
The exact plan stage and examined counts depend on the collection's indexes and server version.
Inspect:
- documents returned,
- keys examined,
- documents examined,
- chosen index/scan stage.
Do not treat an index name alone as proof of efficiency.
Aggregation pipeline
MongoDB aggregation processes documents through stages.
Example:
db.orders.aggregate([
{
$match: {
status: "paid"
}
},
{
$group: {
_id: "$customer_id",
orders: { $sum: 1 },
revenue_cents: { $sum: "$total_cents" }
}
},
{
$sort: {
revenue_cents: -1
}
}
])
Result:
| _id (customer_id) | orders | revenue_cents |
|---|---|---|
| customer-42 | 1 | 4990 |
| customer-77 | 1 | 2500 |
Conceptually:
documents
|
v
$match
|
v
$group
|
v
$sort
Aggregation pipelines are powerful, but broad analytical scans may belong in an analytical system when volume/workload requires it.
Embedded arrays
Suppose an order embeds items:
{
"_id": "order-1001",
"items": [
{"product_id": "book-101", "quantity": 1}
]
}
Find orders containing a product:
db.orders.find({
"items.product_id": "book-101"
})
Result:
| _id | product_id | quantity |
|---|---|---|
| order-1001 | book-101 | 1 |
An array index can support the access pattern, with write/index-size cost.
Missing versus null
Document queries may distinguish a missing field from an explicit null.
The repository includes:
scripts/mongo/null_vs_missing_fields.py
Run it against the local MongoDB setup to observe the difference.
Replacement versus partial update
MongoDB provides both whole-document replacement and operator-based updates.
Repository demo:
scripts/mongo/replace_one_vs_update_one.py
A replacement can remove fields not included in the new document. $set updates selected paths.
Document-modeling demo
The NoSQL chapter adds:
scripts/mongo/nosql_document_modeling.py
It demonstrates an embedded order aggregate and shows that a historical purchase price remains unchanged after the current product price changes.
Run:
cd scripts
bash setup/start_mongo.sh
cd ..
python scripts/mongo/nosql_document_modeling.py
Redis: compute the key first
Example Redis state:
| key | data |
|---|---|
cart:user:42 |
hash: book-101=1, book-205=2 |
session:abc123 |
user=42 |
page:home:views |
integer 99 |
team:7:members |
set: 42, 73 |
leaderboard:2026 |
sorted set: alice=42, bob=27, carol=55 |
A key-value query often begins in application code:
user ID = 42
key = cart:user:42
Then:
HGETALL cart:user:42
Result:
| field | value |
|---|---|
| book-101 | 1 |
| book-205 | 2 |
Unlike MongoDB, the normal operation is not:
search every cart document where customer_id = 42.
The key already encodes the lookup.
Direct key lookup
GET session:abc123
Result:
| key | value |
|---|---|
| session:abc123 | user=42 |
This is the natural key-value access pattern.
If an application repeatedly scans values to find matching properties, the model probably needs another maintained index or a different database.
Atomic update
INCR page:home:views
Input value: 99.
Result:
| command | integer result | stored value after command |
|---|---|---|
INCR page:home:views |
100 | 100 |
A server-side atomic command avoids application read-modify-write races.
Set membership
SISMEMBER team:7:members 42
Result:
| command | result |
|---|---|
SISMEMBER team:7:members 42 |
1 |
or:
SMEMBERS team:7:members
Result:
| member |
|---|
| 42 |
| 73 |
Redis does not promise a particular order for this result.
The data structure is part of the query model.
Sorted-set ranking
ZREVRANGE leaderboard:2026 0 9 WITHSCORES
Result, highest score first:
| rank | member | score |
|---|---|---|
| 1 | carol | 55 |
| 2 | alice | 42 |
| 3 | bob | 27 |
The store can answer rank-oriented queries because the value is modeled as a sorted set.
TTL
SET session:abc123 "user=42" EX 3600
Expiration is part of the operation.
Query:
TTL session:abc123
The command results are:
| command | result |
|---|---|
SET |
OK |
TTL |
integer from 0 to 3600 |
The TTL value depends on elapsed time; -2 means the key has expired or does not exist.
Redis NoSQL exercise
Run:
cd scripts/redis
docker compose up -d
cat nosql_key_design.redis | docker exec -i redis-notes redis-cli
The file demonstrates namespaced keys, TTL, a cart hash, idempotency state, an atomic rate counter, and a sorted-set leaderboard.
Cassandra: query the partition you designed
Wide-column querying is intentionally constrained by primary-key shape.
Table:
CREATE TABLE events_by_customer_month (
customer_id text,
bucket_month text,
event_time timestamp,
event_id uuid,
event_type text,
payload text,
PRIMARY KEY (
(customer_id, bucket_month),
event_time,
event_id
)
) WITH CLUSTERING ORDER BY (event_time DESC);
Example rows in the table:
| customer_id | bucket_month | event_time | event_id | event_type | payload |
|---|---|---|---|---|---|
| customer-42 | 2026-10 | 2026-10-01T12:00:00Z | e1 | viewed | book-101 |
| customer-42 | 2026-10 | 2026-10-01T12:05:00Z | e2 | purchased | book-101 |
Natural query:
SELECT *
FROM events_by_customer_month
WHERE customer_id = 'customer-42'
AND bucket_month = '2026-10'
LIMIT 50;
Result:
| customer_id | bucket_month | event_time | event_id | event_type | payload |
|---|---|---|---|---|---|
| customer-42 | 2026-10 | 2026-10-01T12:05:00Z | e2 | purchased | book-101 |
| customer-42 | 2026-10 | 2026-10-01T12:00:00Z | e1 | viewed | book-101 |
Clustering range
Because event_time is a clustering column:
SELECT *
FROM events_by_customer_month
WHERE customer_id = 'customer-42'
AND bucket_month = '2026-10'
AND event_time >= '2026-10-01T00:00:00Z'
AND event_time < '2026-10-02T00:00:00Z';
Result:
| event_time | event_id | event_type | payload |
|---|---|---|---|
| 2026-10-01T12:05:00Z | e2 | purchased | book-101 |
| 2026-10-01T12:00:00Z | e1 | viewed | book-101 |
Why arbitrary filtering is different
A request such as:
find every event in the cluster whose payload contains "database"
does not match the partition model.
A search engine, analytical system, dedicated query table, or supported secondary indexing feature may be more appropriate.
Cassandra NoSQL exercise
Start Cassandra and load the bucketed table:
cd scripts/cassandra
docker compose up -d
docker exec -i cassandra-notes cqlsh < nosql_bucketed_events.cql
The demo shows the same customer's September and October events in separate partitions.
Neo4j: query a relationship pattern
Example graph data:
| start node | relationship | end node |
|---|---|---|
| Customer 42 | FOLLOWS | Customer 73 |
| Customer 73 | LIKES | Book 101, Database Systems |
| Customer 73 | LIKES | Book 205, SQL Practice |
Graph query:
MATCH (:Customer {customer_id: 42})
-[:FOLLOWS]->(:Customer)
-[:LIKES]->(book:Book)
RETURN DISTINCT book.book_id, book.title
ORDER BY book.book_id;
Result:
| book.book_id | book.title |
|---|---|
| 101 | Database Systems |
| 205 | SQL Practice |
The query expresses a path rather than joins assembled from foreign-key tables.
Start-node lookup matters
A graph traversal usually begins from a selective node:
Customer.customer_id = 42
Use an index/uniqueness constraint for stable identifiers where appropriate.
Then the engine follows relationships.
Variable-depth path
MATCH p =
(:Customer {customer_id: 42})
-[:FOLLOWS*1..3]->
(:Customer)
RETURN p;
For the sample graph above, the result is:
| path start | relationship | path end |
|---|---|---|
| Customer 42 | FOLLOWS | Customer 73 |
The returned path includes its two nodes and the FOLLOWS relationship.
Always consider fan-out. A small depth in a high-degree graph can visit many candidates.
Neo4j NoSQL exercise
Run:
cd scripts
bash setup/start_neo4j.sh
cd ..
python scripts/neo4j/nosql_traversal_demo.py
It creates a small recommendation graph and executes the two-hop traversal.
Same requirement, different query
Requirement:
Retrieve the state for customer 42.
Possible representations:
Document
Input document:
| _id | name | status |
|---|---|---|
| customer-42 | Alice | premium |
db.customers.findOne({ _id: "customer-42" })
Result:
| _id | name | status |
|---|---|---|
| customer-42 | Alice | premium |
Key-value
Input key-value rows:
| key | field | value |
|---|---|---|
customer:42 |
name | Alice |
customer:42 |
status | premium |
GET customer:42
Result:
| name | status |
|---|---|
| Alice | premium |
Wide-column
Input row:
| customer_id | name | status |
|---|---|---|
| customer-42 | Alice | premium |
SELECT *
FROM customer_state
WHERE customer_id = 'customer-42';
Result:
| customer_id | name | status |
|---|---|---|
| customer-42 | Alice | premium |
Graph
Input graph nodes:
| label | customer_id | name | status |
|---|---|---|---|
| Customer | 42 | Alice | premium |
MATCH (c:Customer {customer_id: 42})
RETURN c;
Result:
| customer_id | name | status |
|---|---|---|
| 42 | Alice | premium |
The syntax differs, but the deeper difference is how the store expects data to be addressed and what surrounding operations are cheap.
Cross-entity query
Requirement:
List every customer who bought product DB-101 last year and currently follows an author of that product.
This combines:
- historical orders,
- product relationships,
- current social/author graph.
Trying to make one specialized NoSQL model perfect for every part may be worse than using:
- a transactional source,
- an analytical projection,
- a graph projection.
This is where polyglot persistence can emerge from actual requirements.
Pagination
Every model needs pagination appropriate to its access pattern.
Avoid deep offset pagination when the database has to skip enormous result sets.
Prefer stable cursors/keysets when supported:
last_seen_time
last_seen_id
For Cassandra-style partitions, clustering-key continuation is natural.
For graphs, paginate final result sets rather than unbounded path expansion.
Parameterization and injection
NoSQL query APIs can also be abused if applications build executable query structures directly from untrusted input.
Defenses include:
- use typed driver APIs,
- do not accept arbitrary operators/query documents,
- allowlist dynamic field/sort choices,
- validate IDs and limits,
- use least-privilege database credentials.
"No SQL text" does not mean "no injection risk."
Query plans and profiling
Use each engine's tools:
- MongoDB
explain(), - Redis latency/command metrics,
- Cassandra tracing/metrics cautiously,
- Neo4j
EXPLAIN/PROFILE.
The goal is always the same:
- determine how much data/work the operation touches,
- compare that to the intended access pattern,
- remove unnecessary fan-out/scans.
Do not compare syntax alone
A short query is not automatically fast.
For example:
GET key
is cheap because the key is known.
A MongoDB filter can be cheap with the right index.
A Cassandra query can be cheap because it targets one partition.
A Cypher pattern can be cheap when it begins from one indexed node and traverses bounded relationships.
Performance comes from alignment between model, physical layout, and access pattern.
Query-design checklist
For each query:
- What identity/partition/start node is known?
- How many records/keys/edges can it touch?
- What ordering is required?
- What index or data structure supports it?
- Can the result grow without bound?
- What projection/fields are actually needed?
- What consistency/freshness is required?
- How is pagination handled?
- Can untrusted input alter query structure?
- What planner/metrics prove the operation is efficient?