Relationships & Joins
CRUDCrate automatically loads related entities based on your configuration.
Defining Relationships
Step 1: Sea-ORM Relation Definition
// In your entity file
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
// Has Many: Post has many Comments
#[sea_orm(has_many = "super::comment::Entity")]
Comments,
// Belongs To: Post belongs to User (author)
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::AuthorId",
to = "super::user::Column::Id"
)]
Author,
}
// Implement Related trait for each relationship
impl Related<super::comment::Entity> for Entity {
fn to() -> RelationDef {
Relation::Comments.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Author.def()
}
}
Step 2: Add Join Fields to Model
#[derive(EntityToModels)]
pub struct Model {
#[sea_orm(primary_key)]
#[crudcrate(primary_key)]
pub id: i32,
pub title: String,
#[crudcrate(filterable)]
pub author_id: i32,
// Many relationship (Vec)
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, all))]
pub comments: Vec<super::comment::Comment>,
// One relationship (Option)
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub author: Option<super::user::User>,
}
Join Configuration
join(one)
Load relationship only in get_one (single item) responses:
#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,
# GET /posts/1 - Comments loaded
{
"id": 1,
"title": "Hello World",
"comments": [
{"id": 1, "content": "Great post!"},
{"id": 2, "content": "Thanks!"}
]
}
# GET /posts - Comments NOT loaded
[
{"id": 1, "title": "Hello World"},
{"id": 2, "title": "Another Post"}
]
join(all)
Load relationship in both get_one and get_all responses:
#[crudcrate(non_db_attr, join(one, all))]
pub author: Option<User>,
# GET /posts - Authors loaded in list
[
{
"id": 1,
"title": "Hello World",
"author": {"id": 5, "name": "Alice"}
}
]
Caution: Loading relationships in lists can be expensive. Use selectively.
depth Parameter
Limit recursive loading depth:
// Load Author, but don't load Author's Posts (which would load Posts' Authors, etc.)
#[crudcrate(non_db_attr, join(one, depth = 1))]
pub author: Option<User>,
// Allow 2 levels: Post → Comments → Author
#[crudcrate(non_db_attr, join(one, depth = 2))]
pub comments: Vec<Comment>,
Default: When not specified, recursion is limited to 5 levels for safety.
filterable and sortable inside join(...)
Enable filtering and sorting on columns from related entities:
#[sea_orm(ignore)]
#[crudcrate(
non_db_attr,
join(one, all, depth = 1, filterable("make", "year"), sortable("year", "mileage"))
)]
pub vehicles: Vec<Vehicle>,
This enables dot-notation queries:
# Filter by related entity columns
GET /customers?filter={"vehicles.make":"BMW","vehicles.year_gte":2020}
# Sort by related entity columns
GET /customers?sort=["vehicles.year","DESC"]
Joined filters are resolved automatically by the default handler via scoped sub-queries on the child table. Only whitelisted columns may be filtered or sorted; invalid columns are silently ignored for security.
See Filtering and Sorting for detailed documentation.
Relationship Types
Has Many (One-to-Many)
// Post has many Comments
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub comments: Vec<Comment>,
{
"id": 1,
"comments": [
{"id": 1, "content": "First"},
{"id": 2, "content": "Second"}
]
}
Belongs To (Many-to-One)
// Comment belongs to User
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, all))]
pub user: Option<User>,
{
"id": 1,
"content": "Great post!",
"user": {"id": 5, "name": "Alice"}
}
Has One (One-to-One)
// User has one Profile
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub profile: Option<Profile>,
{
"id": 5,
"name": "Alice",
"profile": {"bio": "Developer", "avatar": "..."}
}
Recursive Relationships
For self-referencing entities:
// Category can have child categories
#[derive(EntityToModels)]
#[sea_orm(table_name = "categories")]
pub struct Model {
#[sea_orm(primary_key)]
#[crudcrate(primary_key)]
pub id: i32,
pub name: String,
pub parent_id: Option<i32>,
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, depth = 1))]
pub children: Vec<Category>,
}
{
"id": 1,
"name": "Electronics",
"children": [
{"id": 2, "name": "Phones", "children": []},
{"id": 3, "name": "Laptops", "children": []}
]
}
Self-referencing fields load one level: each child is returned with an empty children. Omitting depth on a self-referencing field is the same as depth = 1; any value above 1 is a compile error.
Complete Example
// user.rs
#[derive(Clone, Debug, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
#[crudcrate(primary_key, exclude(create, update))]
pub id: i32,
#[crudcrate(filterable, sortable)]
pub name: String,
pub email: String,
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one))]
pub posts: Vec<super::post::Post>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::post::Entity")]
Posts,
}
impl Related<super::post::Entity> for Entity {
fn to() -> RelationDef {
Relation::Posts.def()
}
}
// post.rs
#[derive(Clone, Debug, DeriveEntityModel, Serialize, Deserialize, EntityToModels)]
#[crudcrate(generate_router)]
#[sea_orm(table_name = "posts")]
pub struct Model {
#[sea_orm(primary_key)]
#[crudcrate(primary_key, exclude(create, update))]
pub id: i32,
#[crudcrate(filterable, sortable, fulltext)]
pub title: String,
pub content: String,
#[crudcrate(filterable)]
pub author_id: i32,
// Load author in detail and list views
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, all, depth = 1))]
pub author: Option<super::user::User>,
// Load comments only in detail view
#[sea_orm(ignore)]
#[crudcrate(non_db_attr, join(one, depth = 2))]
pub comments: Vec<super::comment::Comment>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::AuthorId",
to = "super::user::Column::Id"
)]
Author,
#[sea_orm(has_many = "super::comment::Entity")]
Comments,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Author.def()
}
}
impl Related<super::comment::Entity> for Entity {
fn to() -> RelationDef {
Relation::Comments.def()
}
}
Performance Considerations
Batch Loading (N+1 Query Fix)
CRUDCrate automatically uses batch loading for get_all() with join(all) or join(one, all) fields. Instead of issuing one query per parent entity (N+1), it loads all related entities in a single query:
-- 1. Fetch all parent entities
SELECT * FROM posts LIMIT 20;
-- 2. Batch-load ALL related entities for the page (one query per join field)
SELECT * FROM users WHERE id IN (5, 12, 7, ...);
SELECT * FROM comments WHERE post_id IN (1, 2, 3, ...);
This reduces query count from N+1 to 2 for depth=1 joins (1 for parents + 1 per join field). Results are grouped by parent ID in memory.
Depth > 1: Joins with depth > 1 fall back to per-item get_one() calls for nested children. For example, depth = 2 loads level-1 children via batch, then loads level-2 grandchildren per parent.
get_one() behavior: Single-item get_one() with join(one) uses per-item queries. This is acceptable since it’s a single entity lookup.
Batch loading works with any primary key type supported by CRUDResource (UUID, integer, string).
Why not SeaORM 2.0’s Entity Loader?
SeaORM 2.0 ships an Entity Loader (Entity::load().with(...)) that batch-loads
relations, including nested ones. CRUDCrate keeps its own join loading instead,
for three reasons:
- The loader only exists for entities using the new dense format
(
#[sea_orm::model]) or the transitional#[sea_orm::compact_model]; CRUDCrate cannot require either of your entities. - Its
with(...)mechanism selects which relations to populate but has no hook for per-child filter conditions, so scoped access (exclude(scoped)child scopes) cannot be enforced inside the loader’s queries. - It returns SeaORM
ModelExvalues, not CRUDCrate API structs, so each child would still need conversion and its own nested join handling.
At depth 1 both approaches issue the same number of queries. This decision is revisited as the loader API evolves.
Optimization Strategies
- Use
join(one)by default: Only load in detail views - Limit depth: Prevent deep recursive loading
- Add indexes: On foreign key columns
-- Index foreign keys
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_comments_post_id ON comments(post_id);
When to Use join(all)
Use for:
- Small reference data (categories, tags)
- Essential context (author names in list)
- Data needed for display
Avoid for:
- Large collections (comments, history)
- Deep hierarchies
- Optional/rare data
Circular Reference Prevention
CRUDCrate detects and warns about potential infinite loops:
Warning: Potential circular reference detected in Post.author -> User.posts -> Post
Consider using depth limit: join(one, depth = 1)
Always use depth limits when entities reference each other.
Next Steps
- Learn about Field Exclusion
- Configure Default Values
- Set up Error Handling