Eager loading and N+1
N+1 occurs when a list query is followed by one relationship query for every row. With N teams, this code runs 1 team SELECT and N player SELECTs. Latency grows even when each individual query looks inexpensive. Use the models from relationships.
Recognizing the pattern
Section titled “Recognizing the pattern”async def roster_with_extra_queries(): teams = await Team.orderBy("id").get() result = [] for team in teams: players = await team.players().get() result.append({"team": team.name, "players": players.serialize()}) return resultsequenceDiagram
participant C as Controller
participant D as Database
C->>D: SELECT teams
D-->>C: N teams
loop One query per team
C->>D: SELECT players WHERE team_id = ?
end
Note over C,D: Lazy: 1 + N queries
C->>D: SELECT teams
C->>D: SELECT players WHERE team_id IN (...)
Note over C,D: Eager: 2 queries for this relationBatch loading and reuse
Section titled “Batch loading and reuse”async def roster_batched(): teams = await Team.withRelations("players", "profile", "tags").orderBy("id").get() return [ { "team": team.toDict(), "players": team.getRelation("players").serialize(), "profile": ( team.getRelation("profile").toDict() if team.getRelation("profile") is not None else None ), "tags": team.getRelation("tags").serialize(), } for team in teams ]For a nonempty batch with the required keys, this runs 1 team query + 1 player query + 1 profile query + 2 tag queries (pivot and related models): 5, independent of team count. Without pivot ids it skips the tag query; keyless batches skip related queries. Relationships load in declaration order, not through gather.
getRelation reads the stored result; calling team.players() again creates another builder and may reintroduce N+1. relationLoaded("profile") distinguishes an unloaded relation from a loaded relation without a match (None). There is no automatic relationship property loading.
withRelations
Section titled “withRelations”def withRelations(self, *names: str) -> SelfRegisters unique relationship method names in order. Returns the same builder without SQL. After hydration, each name builds a relation without its individual constraint, gathers batch keys, queries and assigns through setRelation. Missing names or methods not returning Relation raise RelationNotFoundException when rows exist; empty results do not validate names.
async def teams_with_players(): return await Team.withRelations("players").get()def load(self, *names: str) -> SelfSynchronous fluent alias of withRelations. It belongs to the builder, not an already retrieved instance: await team.load(...) is not an available API. Accepts direct names as separate arguments.
async def teams_with_profiles(): return await Team.query().load("profile").get()Projections, filters and deeper levels
Section titled “Projections, filters and deeper levels”- Keep local/foreign keys needed for grouping in SELECT.
Team.select("name").withRelations("players")omitsidand cannot associate its players correctly. - Dotted paths such as
"players.team"and mapping/callback arguments towithRelationsare not interpreted. Declare direct relationship methods for read variants or query further levels explicitly. - Filters declared in a relationship method apply to the batch. A
limitthere limits the entire related batch query; it does not mean “N rows per parent”. - Related-model scopes and soft deletion visibility still apply. The cache belongs to each instance and can become stale after writes; query again for updated data.
- Confirm the pattern by counting SELECT calls with a connection wrapper in tests or examining SQL observed by your infrastructure. Compare several parents, not just one. No automatic N+1 detector is exposed here.
See collections and pagination to load only the needed page and testing to verify results and operation counts.