Guide / Reinhardt 0.3.15
The network hidden inside an ORM
In this guide
A list page displays 100 posts and the name of each post's author. There are only ten authors. To fill in their names, we can fetch an author for each post or collect the authors we need and fetch them together. In this example, those approaches take 101 queries and two, while returning the same cards in the same order. Changing which authors the posts refer to also reveals a case with eleven queries and no N+1 detector finding.
Those are observed counts from the downloadable Rust example. It uses Reinhardt 0.3.15 and a real in-memory SQLite database. SQLite runs in the application process here: the network diagrams explain what the query pattern could mean with a remote database. They are not network measurements.
Reinhardt Press is the Reinhardt project's own developer outreach publication. The example is small enough to inspect and change before adopting anything in an application.
One relationship, two ways to retrieve it
Each post belongs to an author. Both models use #[model], and Post declares its author relationship with #[rel] and ForeignKeyField<Author>:
use reinhardt::db::associations::ForeignKeyField;
use reinhardt::model;
use serde::{Deserialize, Serialize};
#[model(app_label = "orm_network", table_name = "posts")]
#[derive(Serialize, Deserialize)]
pub struct Post {
#[field(primary_key = true)]
pub id: i64,
#[field(max_length = 200)]
pub title: String,
#[rel(foreign_key, on_delete = Cascade)]
pub author: ForeignKeyField<Author>,
}In 0.3.15, the model macro generates the backing author_id field. We do not declare it as a separate scalar model field. The database column and explicit lookups below use that generated identifier; declaring the relationship does not itself load the author name.
The sample creates its schema and synthetic data explicitly so that setup is easy to distinguish from the reads under inspection. This article does not test migration generation. Author is another #[model] with an ID and a name. The complete source and package configuration are in the download.
Both implementations begin with the same query:
QuerySet::<Post>::new()
.order_by(&["id"])
.limit(count)
.all_with_db(db)
.await?This loads the posts, including their author IDs. In the first implementation, each iteration requests an author before moving on. This excerpt omits only card assembly:
for post in posts(db, count).await? {
let author = QuerySet::<Author>::new()
.filter(Filter::new(
"id",
FilterOperator::Eq,
FilterValue::Integer(post.author_id),
))
.first_with_db(db)
.await?
.ok_or("missing author")?;
// Assemble the card from post and author.
}The application explicitly asks for these lookups. For every post returned, the loop executes one author SELECT, including when the same author appeared earlier. There is no author cache between iterations. The scoped count is therefore one post query plus N author queries.
Collect the questions before asking them
The batched implementation collects the distinct author IDs from the selected posts, then fetches those authors together:
let ids = posts
.iter()
.map(|post| post.author_id)
.collect::<BTreeSet<_>>();
let authors = QuerySet::<Author>::new()
.filter(Filter::new(
"id",
FilterOperator::In,
FilterValue::List(ids.into_iter().map(FilterValue::Integer).collect()),
))
.all_with_db(db)
.await?;The IN filter uses bound values. The complete implementation builds an ID-to-name map from these authors, then walks the original posts to assemble the cards. That last step preserves post order regardless of the author query's return order. A missing author is an error in both implementations.
An empty post list returns before the author query. For a nonempty list in this fixture, the total is two SELECTs. This is explicit batching with QuerySet; it does not exercise prefetch_related().
What the executable checks
The run used Rust 1.96.0 and the published reinhardt-web / reinhardt-db 0.3.15 packages. The supplied Cargo.lock pins the resolved dependencies. The fixture contains 100 posts and indexed author primary keys.
| Posts | Distinct authors used | Per-post SELECTs | Batched SELECTs | Per-post findings |
|---|---|---|---|---|
| 0 | 0 | 1 | 1 | 0 |
| 1 | 1 | 2 | 2 | 0 |
| 5 | 5 | 6 | 2 | 0 |
| 10 | 10 | 11 | 2 | 1 |
| 100 | 10 | 101 | 2 | 1 |
| 10 | 1 | 11 | 2 | 0 |

The first five cases share a ten-author fixture. The final case uses a fresh one-author fixture. Every case checks all card IDs, titles, author names, and their order against independently constructed expected values. Matching only the number of rows would miss wrong names or ordering.
NPlusOneScope::run_with_report observes the real ORM queries. Schema creation and inserts run outside these scopes. The sample asserts the exact recorded counts and that no query samples were dropped; all assertions passed. These counts cover successful ORM SELECT operations in the chosen scope, not every statement a database connection might execute internally.
A detector can be quiet while queries repeat
The sample wraps each implementation like this:
let (result, report) =
NPlusOneScope::warn("posts.naive", NPlusOneConfig::default())
.run_with_report(naive(db, count))
.await;
let cards = result?;In 0.3.15, the defaults require at least ten executions of a query shape and three distinct parameter signatures. A scope retains at most 1,024 query samples. These are documented configuration fields, not properties inferred from a warning message. NPlusOneConfig at the 0.3.15 source commit
For ten posts belonging to ten authors, the report contains one finding for the repeated author query:
SELECT * FROM "authors" WHERE "id" = ?For ten posts belonging to one author, it contains no findings. The same lookup still executes ten times, but only one parameter signature is used. Five posts also produce no finding because the execution count is below ten. The batched path produces no findings in all six scenarios.
The detector is a diagnostic with thresholds. Its report does not prove an efficient query plan. Keep correctness assertions and query counts alongside the findings when evaluating a change.
The network is a model of an additional cost
Suppose, for illustration, a remote database adds 20 ms of round-trip waiting to each sequential query. Ignoring other costs, 101 queries contribute 101 × 20 ms = 2,020 ms of waiting; two contribute 2 × 20 ms = 40 ms.
Those times are hypothetical. The SQLite experiment measures no network latency and makes no wall-clock speed claim. Database execution, transferred data, caching, connection behavior, and the rest of the request all affect an application's response time. Reducing this modeled waiting component does not establish a corresponding application speedup.

The diagram is useful because it makes a sequential dependency visible: the loop waits for an answer before asking the next question.
Boundaries worth testing next
Batching allocates an author map. Its identifier list must also fit the backend's parameter limits; this fixture needs at most ten distinct IDs. Large workloads can require bounded batches. A JOIN is another candidate for a particular page, but this experiment does not measure it.
The fixture performs no concurrent writes. It does not establish the snapshot consistency or transaction behavior a live application may need. Nor does it compare database backends, run a load test, or demonstrate automatic related loading APIs.
These limits are part of the result. The experiment establishes an exact change in query count while preserving its defined output.
Run it and change one thing
Download and extract the example and lockfile. With Rust 1.96.0 or later and a working native Rust toolchain:
cd orm-network
cargo run --lockedThe first build downloads dependencies. No database server or Docker is needed. The program exits unsuccessfully on a failed assertion or database error, and prints the evidence as JSON after all checks pass.
Try changing the distinct author count while leaving the post count fixed. Predict the query count and the detector's findings separately. The README identifies the values to change together.
If a variation disagrees with its expected result, preserve the small reproduction, command, Rust version, lockfile, and output. Read the current Reinhardt contribution guide before proposing a framework change. The detector lives in crates/reinhardt-db/src/orm/n_plus_one.rs; QuerySet::all_with_db connects ORM execution to its instrumentation. A useful contribution can begin with one well-specified case and its expected result.