ERD and AutoLoad
Manual resolve_* methods are the right entry point. But once the same relationships start repeating across multiple response models, the problem changes.
You are no longer asking "how do I load this field?" You are asking "where should the source of truth for this relationship live?"
That is the point where ERD mode becomes worth the upfront cost.
The Duplication Signal
If your codebase starts to accumulate patterns like these, the relationships are probably ready to move into ERD:
TaskCard.resolve_ownerTaskDetail.resolve_ownerSprintBoard.resolve_tasksSprintReport.resolve_tasks
The loader logic may still be correct, but the relationship knowledge is now duplicated.
Cost vs Benefit
| Question | Manual Core API | ERD + AutoLoad |
|---|---|---|
| First endpoint | Faster | Slower |
| Upfront setup | Low | Medium |
| Reusing the same relation in many models | Repetitive | Centralized |
| Changing a relation later | Update many resolve_* methods |
Update one declaration |
| GraphQL and MCP reuse | Separate work | Natural extension |
The Same Scenario in ERD Mode
from typing import Annotated, Optional
from pydantic import BaseModel
from pydantic_resolve import (
Loader,
Resolver,
Relationship,
base_entity,
build_list,
build_object,
config_global_resolver,
)
# --- Fake database ---
USERS = {
7: {"id": 7, "name": "Ada"},
8: {"id": 8, "name": "Bob"},
}
TASKS = [
{"id": 10, "title": "Design docs", "sprint_id": 1, "owner_id": 7},
{"id": 11, "title": "Refine examples", "sprint_id": 1, "owner_id": 8},
]
async def user_loader(user_ids: list[int]):
users = [USERS.get(uid) for uid in user_ids]
return build_object(users, user_ids, lambda u: u.id)
async def task_loader(sprint_ids: list[int]):
tasks = [t for t in TASKS if t["sprint_id"] in sprint_ids]
return build_list(tasks, sprint_ids, lambda t: t["sprint_id"])
# --- Entity definitions ---
BaseEntity = base_entity()
class UserEntity(BaseModel, BaseEntity):
id: int
name: str
class TaskEntity(BaseModel, BaseEntity):
__relationships__ = [
Relationship(fk='owner_id', name='owner', target=UserEntity, loader=user_loader)
]
id: int
title: str
owner_id: int
class SprintEntity(BaseModel, BaseEntity):
__relationships__ = [
Relationship(fk='id', name='tasks', target=list[TaskEntity], loader=task_loader)
]
id: int
name: str
diagram = BaseEntity.get_diagram()
AutoLoad = diagram.create_auto_load()
config_global_resolver(diagram)
# --- Response models (no resolve_* methods needed) ---
class TaskView(TaskEntity):
owner: Annotated[Optional[UserEntity], AutoLoad()] = None
class SprintView(SprintEntity):
tasks: Annotated[list[TaskView], AutoLoad()] = []
task_count: int = 0
def post_task_count(self):
return len(self.tasks)
# --- Resolve ---
raw_sprints = [{"id": 1, "name": "Sprint 24"}]
sprints = [SprintView.model_validate(s) for s in raw_sprints]
sprints = await Resolver().resolve(sprints)
print(sprints[0].model_dump())
# {'id': 1, 'name': 'Sprint 24',
# 'tasks': [
# {'id': 10, 'title': 'Design docs', 'owner_id': 7,
# 'owner': {'id': 7, 'name': 'Ada'}},
# {'id': 11, 'title': 'Refine examples', 'owner_id': 8,
# 'owner': {'id': 8, 'name': 'Bob'}},
# ],
# 'task_count': 2}
What Changed
resolve_ownerdisappeared from the view model.resolve_tasksdisappeared from the view model.- Relationship declarations moved into
__relationships__. post_task_countstayed exactly where it belongs.
That last point matters: ERD removes repeated relationship wiring, but it does not replace business-specific post-processing.
Two Ways to Declare the ERD
Style 1: Inline __relationships__ on Entity Classes
BaseEntity = base_entity()
class TaskEntity(BaseModel, BaseEntity):
__relationships__ = [
Relationship(fk='owner_id', name='owner', target=UserEntity, loader=user_loader)
]
id: int
title: str
owner_id: int
diagram = BaseEntity.get_diagram()
This style works well when the entity class is already owned by the current application layer and you are comfortable attaching relationship metadata directly to it.
Style 2: External ErDiagram(...) Declaration
from pydantic_resolve import Entity, ErDiagram
class UserEntity(BaseModel):
id: int
name: str
class TaskEntity(BaseModel):
id: int
title: str
owner_id: int
class SprintEntity(BaseModel):
id: int
name: str
diagram = ErDiagram(
entities=[
Entity(kls=TaskEntity, relationships=[
Relationship(fk='owner_id', name='owner', target=UserEntity, loader=user_loader)
]),
Entity(kls=SprintEntity, relationships=[
Relationship(fk='id', name='tasks', target=list[TaskEntity], loader=task_loader)
]),
Entity(kls=UserEntity, relationships=[]),
],
)
When External Declaration Is a Better Fit
External ErDiagram(...) declaration is often the better choice when:
- you do not want to modify the entity classes themselves
- the same entity classes are shared across multiple modules or services
- you want one centralized place to inspect all relationship definitions
- the source classes come from another package or a compatibility layer
In short:
- use
__relationships__when relationship metadata belongs naturally on the entity type - use external
ErDiagram(...)when relationship metadata should stay separate from the type definition
How AutoLoad Works
AutoLoad is not magic. It is an annotation that the resolver recognizes and converts into a resolve_* method at analysis time.
AutoLoad = diagram.create_auto_load()
class TaskView(TaskEntity):
owner: Annotated[Optional[UserEntity], AutoLoad()] = None
When the resolver scans this class, it:
- Finds the
AutoLoad()annotation on theownerfield. - Looks up the
Relationshipwithname='owner'from the diagram. - Generates an equivalent
resolve_ownermethod that calls the loader with the FK value.
The AutoLoad(origin='tasks') parameter lets you specify a different relationship name when the field name does not match:
The diagram and AutoLoad Must Match
This setup is not just ceremony:
diagram = BaseEntity.get_diagram()
AutoLoad = diagram.create_auto_load()
config_global_resolver(diagram)
create_auto_load() embeds diagram-specific relationship metadata into the annotation, so the resolver must be configured with the same diagram.
If you use a custom resolver instead of the global one:
from pydantic_resolve import config_resolver
MyResolver = config_resolver('MyResolver', er_diagram=diagram)
result = await MyResolver().resolve(data)
Relationship Types
One-to-One (build_object)
Relationship(
fk='owner_id', # the FK field on this entity
name='owner', # unique relationship name
target=UserEntity, # single target entity
loader=user_loader # returns one item per key
)
One-to-Many (build_list)
Relationship(
fk='id', # the PK field on this entity
name='tasks', # unique relationship name
target=list[TaskEntity], # list target
loader=task_loader # returns a list of items per key
)
Handling None FK Values
Relationship(
fk='owner_id',
name='owner',
target=UserEntity,
loader=user_loader,
fk_none_default=None # return None when FK is None
)
# Or use a factory:
Relationship(
fk='owner_id',
name='owner',
target=UserEntity,
loader=user_loader,
fk_none_default_factory=lambda: AnonymousUser()
)
Multiple Relationships from the Same FK
class TaskEntity(BaseModel, BaseEntity):
__relationships__ = [
Relationship(fk='owner_id', name='author', target=UserEntity, loader=user_loader),
Relationship(fk='owner_id', name='reviewer', target=UserEntity, loader=reviewer_loader),
]
id: int
owner_id: int
Custom FK Transformation with fk_fn
When the FK value needs transformation before being passed to the loader:
Relationship(
fk='tag_ids', # comma-separated string "1,2,3"
name='tags',
target=list[TagEntity],
loader=tag_loader,
load_many=True, # use load_many instead of load
load_many_fn=lambda ids: ids.split(',') if ids else []
)
Migrating from Manual resolve_* to ERD
The migration path is incremental:
- Define entities that mirror your existing response models.
- Add
__relationships__or externalErDiagramdeclarations. - Create
AutoLoadandconfig_global_resolver. - Replace
resolve_*methods withAutoLoad()annotations. - Keep
post_*methods unchanged.
You can mix manual and ERD-driven resolution in the same project:
class TaskView(TaskEntity):
owner: Annotated[Optional[UserEntity], AutoLoad()] = None # ERD-driven
comments: list[CommentView] = [] # still manual
def resolve_comments(self, loader=Loader(comment_loader)): # manual
return loader.load(self.id)
Handling Circular Imports
When entities reference each other through target, you may encounter circular import issues.
Same-Module String References
class TaskEntity(BaseModel, BaseEntity):
__relationships__ = [
# String 'UserEntity' resolved within same module
Relationship(fk='owner_id', name='owner', target='UserEntity', loader=user_loader)
]
Cross-Module References
# In app/models/task.py
class TaskEntity(BaseModel, BaseEntity):
__relationships__ = [
Relationship(
fk='owner_id',
target='app.models.user:UserEntity', # module.path:ClassName
name='owner',
loader=user_loader
)
]
The _resolve_ref function supports:
- Simple class names:
'UserEntity'(looked up in the current module) - Module path syntax:
'app.models.user:UserEntity' - List generics:
list['UserEntity']orlist['app.models.user:UserEntity']
When Not to Use ERD Yet
Stay with manual Core API when:
- you only have a few response models
- the relationship structure is still moving quickly
- the duplication cost is not real yet
ERD is valuable, but it is a scaling step, not a rite of passage.
Next
Continue to DataLoader Deep Dive to understand how batching works under the hood, or jump to ERD with DefineSubset to learn how to hide internal FK fields from responses.