select_related & prefetch_related

The N+1 problem

Without relation loading, fetching posts and their authors requires N+1 queries:

#![allow(unused)]
fn main() {
// ❌ N+1 — 1 query for posts + N queries for authors
let posts = Post::filter(&pool).all().await?;
for post in &posts {
    let author = get::<_, User>(&pool, &post.author_id.to_sql_value()).await?;
    // ^ one query per post!
}
}

Rango solves this with select_related and prefetch_related.

For direct foreign keys: one query, with a LEFT JOIN per related model:

#![allow(unused)]
fn main() {
let posts = Post::filter(&pool)
    .select_related(Post::AUTHOR_ID)
    .all()
    .await?;

for post in &posts {
    let author = post.related::<User>(); // &User, zero SQL
    println!("{} by {}", post.title, author.name);
}
}

SQL generated:

SELECT "__m".*, "__r0"."id" AS "__r0_id", "__r0"."name" AS "__r0_name", ...
FROM (SELECT * FROM "post" WHERE ... ORDER BY ... LIMIT 1000) AS "__m"
LEFT JOIN "user" AS "__r0" ON "__r0"."id" = "__m"."author_id"

Filters, ordering and LIMIT apply to the posts before the join. A post whose FK is NULL comes back without a related object: .related::<User>() panics on it, so only use select_related on nullable FKs when you know they are set.

For reverse foreign keys (Comment.post_id → Post), one second batched query:

#![allow(unused)]
fn main() {
let posts = Post::filter(&pool)
    .prefetch_related(Comment::POST_ID)
    .all()
    .await?;

for post in &posts {
    let comments = post.prefetched::<Comment>(); // &[Comment], zero SQL
    println!("{} has {} comments", post.title, comments.len());
}
}

SQL generated:

-- Query 1
SELECT * FROM "post"

-- Query 2
SELECT * FROM "comment" WHERE "post_id" = ANY($1)  -- $1: array of post ids

SQLite and MySQL have no array parameters: they send WHERE "post_id" IN (?, ?, ...) in batches of 900 keys. Every backend runs the main query and the prefetch queries on one connection, and all() returns Vec<WithRelated<M>> everywhere (it derefs to M, so post.title works as before).

Many-to-many

Pass the ManyToMany field constant (#[derive(Model)] generates Article::TAGS for a tags: ManyToMany<Tag> field). One query joins the pivot table:

#![allow(unused)]
fn main() {
let articles = Article::filter(&pool)
    .prefetch_related(Article::TAGS)
    .all()
    .await?;

for article in &articles {
    let tags = article.prefetched::<Tag>(); // &[Tag], zero SQL
}
}
SELECT "__p"."article_id" AS "__m2m_key", "__t".*
FROM "tag" AS "__t"
INNER JOIN "article_tag" AS "__p" ON "__t"."id" = "__p"."tag_id"
WHERE "__p"."article_id" = ANY($1)

Combining both

#![allow(unused)]
fn main() {
let posts = Post::filter(&pool)
    .select_related(Post::AUTHOR_ID)      // FK → JOIN
    .prefetch_related(Comment::POST_ID)   // reverse → batch IN
    .all()
    .await?;

for post in &posts {
    let author   = post.related::<User>();
    let comments = post.prefetched::<Comment>();
}
}

Panics

Calling .related::<R>() or .prefetched::<R>() without the corresponding select_related / prefetch_related call panics with a clear message:

thread panicked: select_related<User> was not loaded;
add .select_related(..) for User to the QueryBuilder chain

Large result sets

Both queries run on the same pooled connection. The keys are bound as one array parameter, so the statement is the same for 1 or 50 000 posts and is prepared once.

prefetch_related should only be used with paginated results. Loading 10 000 posts and prefetching all their comments will use significant memory. Always combine with .limit().