Relation Load Strategy
Hysteria ORM gives you control over how relations are fetched via the strategy option on .load().
// explicit strategy
await sql.from(User).load("posts", { strategy: "join" }).many();
// strategy + query builder
await sql
.from(User)
.load("posts", (qb) => qb.where("published", true), { strategy: "batched" })
.many();
There are three strategies: auto (default), join, and batched.
auto — Recommended
// auto is the default, no need to specify it explicitly
await sql.from(User).load("posts").many();
// or explicitly
await sql.from(User).load("posts", { strategy: "auto" }).many();
Auto inspects the query context at runtime and selects the most appropriate strategy. It is the recommended choice for almost every use case.
Selection rules
| Condition | Strategy chosen |
|---|---|
hasMany or manyToMany with limit or offset on the relation | batched |
Single parent (parentCount === 1, e.g. after .one()) | join |
manyToMany with ≤ 10 parents | join |
| Any relation with ≤ 9 parents | join |
| Everything else (large parent sets) | batched |
Rules are evaluated in the order shown above — the first match wins.
join — Single-query loading
await sql.from(User).load("posts", { strategy: "join" }).many();
Fetches the parent and all related records in a single SQL query using LEFT JOIN.
hasOne/hasMany: joins on the foreign key.belongsTo: joins on the primary key of the related model.manyToMany: double join through the junction table.
Pros
- One round-trip to the database.
- Fastest for small parent sets or single records.
Cons
- Can cause large result sets (Cartesian product) with many-to-many or large datasets.
- Does not support nested
.load()calls — if the relation QB contains nested loads, it automatically falls back tobatched. limit/offseton ahasManyrelation cannot be expressed correctly in a JOIN —autowill never choosejoinfor those cases.
batched — Multiple-query loading
await sql.from(User).load("posts", { strategy: "batched" }).many();
Fetches related records in a separate query using WHERE foreignKey IN (...), one query per relation type.
hasMany/manyToManywithlimit/offset: uses a CTE withROW_NUMBER()to apply per-parent pagination correctly.
Pros
- Safe for large datasets — no Cartesian product risk.
- Supports nested
.load()calls. limit/offsetonhasManyandmanyToManywork correctly per parent.
Cons
- Two or more queries per
.load()call (one for the parent, one per relation).
API reference
type RelationLoadStrategy = "auto" | "join" | "batched";
interface LoadOptions {
/** @default 'auto' */
strategy?: RelationLoadStrategy;
/** Separator for JOIN column aliases. @default '__' */
joinSeparator?: string;
}
All .load() overloads accept an optional LoadOptions object:
// relation only
.load("posts")
// relation + query builder
.load("posts", (qb) => qb.where("published", true))
// relation + options
.load("posts", { strategy: "batched" })
// relation + query builder + options
.load("posts", (qb) => qb.orderBy("id", "desc"), { strategy: "join" })
When to override auto
In practice, auto covers the vast majority of cases correctly. The two situations where an explicit override makes sense are:
- Force
joinfor a known-small dataset — you know the parent set will always be small (e.g. a fixed enum table) and want to guarantee a single query. - Force
batchedfor safety — you expect highly variable parent counts and want predictable, consistent query plans regardless of the current result set size.
When in doubt, leave the strategy as auto. It will make the right call based on the actual data and query shape at runtime.