Skip to main content

pydantic-resolve turns pydantic from a static data container into a powerful composable component.

Project description

Pydantic Resolve

Declarative data assembly for Pydantic — eliminate N+1 queries with minimal code.

pypi PyPI Downloads Python Versions CI

中文版


pydantic-resolve is inspired by GraphQL. It builds database-independent application-layer Entity Relationship Diagrams using DataLoader, providing rich data assembly and post-processing capabilities. It can also auto-generate GraphQL queries and MCP services.

Why pydantic-resolve?

Core capabilities:

Feature What it does
Automatic Batching DataLoader eliminates N+1 queries automatically
Declarative Assembly Declare dependencies, framework handles the rest
ER Diagram + AutoLoad Define entity relationships, auto-resolve related data
GraphQL Support Generate schema from ERD, query with dynamic models
MCP Integration Expose GraphQL APIs to AI agents with progressive disclosure

One line to fetch nested data:

class Task(BaseModel):
    owner_id: int
    owner: Optional[User] = None

    def resolve_owner(self, loader=Loader(user_loader)):
        return loader.load(self.owner_id)  # That's it!

# Resolver automatically batches all owner lookups into one query
result = await Resolver().resolve(tasks)

Quick Start

Install

pip install pydantic-resolve

The N+1 Problem

# Traditional: 1 + N queries
for task in tasks:
    task.owner = await get_user(task.owner_id)  # N queries!

The pydantic-resolve Solution

from pydantic import BaseModel
from typing import Optional, List
from pydantic_resolve import Resolver, Loader, build_list

# 1. Define your loaders (batch queries)
async def user_loader(ids: list[int]):
    users = await db.query(User).filter(User.id.in_(ids)).all()
    return build_list(users, ids, lambda u: u.id)

async def task_loader(sprint_ids: list[int]):
    tasks = await db.query(Task).filter(Task.sprint_id.in_(sprint_ids)).all()
    return build_list(tasks, sprint_ids, lambda t: t.sprint_id)

# 2. Define your schema with resolve methods
class TaskResponse(BaseModel):
    id: int
    name: str
    owner_id: int

    owner: Optional[dict] = None
    def resolve_owner(self, loader=Loader(user_loader)):
        return loader.load(self.owner_id)

class SprintResponse(BaseModel):
    id: int
    name: str

    tasks: List[TaskResponse] = []
    def resolve_tasks(self, loader=Loader(task_loader)):
        return loader.load(self.id)

# 3. Resolve - framework handles batching automatically
@app.get("/sprints")
async def get_sprints():
    sprints = await get_sprint_data()
    return await Resolver().resolve([SprintResponse.model_validate(s) for s in sprints])

Result: 1 query per loader, regardless of data depth.


Core Concepts

Resolve: Declarative Data Loading

Instead of imperative data fetching, declare what you need:

class Task(BaseModel):
    owner_id: int
    owner: Optional[User] = None

    def resolve_owner(self, loader=Loader(user_loader)):
        return loader.load(self.owner_id)

The framework:

  1. Collects all owner_id values
  2. Batches them into one query
  3. Maps results back to correct objects

DataLoader: Automatic Batching

DataLoader batches multiple requests within the same event loop tick:

# Without DataLoader: 100 tasks = 100 user queries
# With DataLoader: 100 tasks = 1 user query (WHERE id IN (...))

async def user_loader(user_ids: list[int]):
    return await db.query(User).filter(User.id.in_(user_ids)).all()

Expose & Collect: Cross-layer Data Flow

In nested data structures, parent and child nodes often need to share data. Traditional approaches require explicit parameter passing or tight coupling. pydantic-resolve provides two declarative mechanisms:

  • ExposeAs: Parent nodes expose data to all descendants (downward flow)
  • SendTo + Collector: Child nodes send data to parent collectors (upward flow)

This creates a clean separation — parent doesn't need to know child's structure, and child doesn't need explicit parent references.

from pydantic_resolve import ExposeAs, Collector, SendTo
from typing import Annotated

# 1. Parent EXPOSES data to descendants (downward flow)
class Story(BaseModel):
    name: Annotated[str, ExposeAs('story_name')]
    tasks: List[Task] = []

# 2. Child ACCESSES ancestor context (no explicit parent reference needed)
class Task(BaseModel):
    def post_full_path(self, ancestor_context):
        return f"{ancestor_context['story_name']} / {self.name}"

# 3. Child SENDS data to parent collector (upward flow)
class Task(BaseModel):
    owner: Annotated[User, SendTo('contributors')] = None

class Story(BaseModel):
    contributors: List[User] = []
    def post_contributors(self, collector=Collector('contributors')):
        return collector.values()  # Auto-deduplicated list of all task owners

Use cases:

  • Pass configuration/context down to nested objects (e.g., user permissions, locale)
  • Aggregate results up from nested objects (e.g., collect all unique tags from posts)

Declarative Mode: ER Diagram + AutoLoad

Quick Start and Core Concepts demonstrate pydantic-resolve's Core API: writing resolve_* methods and manually specifying Loaders. For simple use cases, this is sufficient.

When a project involves multiple interrelated entities, pydantic-resolve offers a Declarative API: define entity relationships and default loaders in an ER Diagram, then use AutoLoad to auto-generate the corresponding resolve methods.

Declarative API is built on top of Core API. AutoLoad fields generate equivalent resolve_* methods at runtime, so both modes can be freely mixed — you can still use post_* methods in Declarative Mode, or fall back to hand-written resolve_* for specific fields.

Core API Declarative API
Approach Hand-write resolve_* + specify Loader Define ER Diagram + AutoLoad
Control Full control Convention over configuration
Best for Simple projects, one-off data loading Multiple related entities, GraphQL/MCP needed
Relationships Scattered across Response classes Centralized in ER Diagram

Define Entities and Relationships

Create a base class with base_entity(), then define relationships in __relationships__:

from pydantic import BaseModel
from typing import Annotated, Optional
from pydantic_resolve import base_entity, Relationship, config_global_resolver

BaseEntity = base_entity()

class UserEntity(BaseModel, BaseEntity):
    id: int
    name: str

class TaskEntity(BaseModel, BaseEntity):
    __relationships__ = [
        # Loader can query Postgres, call RPC, or fetch from Redis
        # API consumers don't need to know where data comes from
        Relationship(fk='owner_id', target=UserEntity, name='owner', loader=user_loader)
    ]
    id: int
    name: str
    owner_id: int  # Internal FK, can be hidden from API

diagram = BaseEntity.get_diagram()
AutoLoad = diagram.create_auto_load()
config_global_resolver(diagram)

You can also use external declaration (ErDiagram + Entity) to separate relationship definitions from entity classes.

Use AutoLoad

After defining the ER Diagram, annotate fields with AutoLoad() in Response models:

from pydantic_resolve import DefineSubset

class TaskResponse(TaskEntity):
    owner: Annotated[Optional[UserEntity], AutoLoad()] = None
    # AutoLoad generates resolve_owner based on TaskEntity's __relationships__

# Usage is identical to Core API
result = await Resolver().resolve(tasks)

Use DefineSubset to selectively expose fields and hide internal FKs:

class TaskResponse(DefineSubset):
    __subset__ = (TaskEntity, ('id', 'name'))  # owner_id excluded
    owner: Annotated[Optional[UserEntity], AutoLoad()] = None

When to Use Declarative Mode

Declarative Mode is a good fit when:

  • The project has 3+ interrelated entities
  • You need to generate GraphQL schema or MCP services
  • The team needs centralized relationship management
  • You want to hide FK fields from API contracts

Core API is sufficient when:

  • Only a few data loading requirements
  • Simple data source
  • No GraphQL or MCP needed

→ Full ERD-Driven Guide

Integrations

GraphQL

Generate GraphQL schema from ERD and execute queries:

from pydantic_resolve.graphql import GraphQLHandler

handler = GraphQLHandler(diagram)
result = await handler.execute("{ users { id name posts { title } } }")

→ GraphQL Documentation

MCP

Expose GraphQL APIs to AI agents:

from pydantic_resolve import AppConfig, create_mcp_server

mcp = create_mcp_server(apps=[AppConfig(name="blog", er_diagram=diagram)])
mcp.run()

→ MCP Documentation

Visualization

Interactive ERD exploration with fastapi-voyager:

from fastapi_voyager import create_voyager

app.mount('/voyager', create_voyager(app, er_diagram=diagram))

pydantic-resolve vs GraphQL

Feature GraphQL pydantic-resolve
N+1 Prevention Manual DataLoader setup Built-in automatic batching
Type Safety Separate schema files Native Pydantic types
Learning Curve Steep (Schema, Resolvers, Loaders) Gentle (just Pydantic)
Debugging Complex introspection Standard Python debugging
Integration Requires dedicated server Works with any framework
Query Flexibility Any client can query anything Explicit API contracts

Resources


License

MIT License

Author

tangkikodo (allmonday@126.com)

Project details


Release history Release notifications | RSS feed

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pydantic_resolve-4.0.0a1.tar.gz (1.3 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pydantic_resolve-4.0.0a1-py3-none-any.whl (107.0 kB view details)

Uploaded Python 3

File details

Details for the file pydantic_resolve-4.0.0a1.tar.gz.

File metadata

  • Download URL: pydantic_resolve-4.0.0a1.tar.gz
  • Upload date:
  • Size: 1.3 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.2 {"installer":{"name":"uv","version":"0.11.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for pydantic_resolve-4.0.0a1.tar.gz
Algorithm Hash digest
SHA256 1b5011c6a7708254af42b5a0ebf599d84d0c472b729ff62af60fe8197032f077
MD5 ce613c9905b35036399cba6cd910e0fc
BLAKE2b-256 61db34d0126ec2468341a9d1fbb74f26aad7afd19b5dfe44a5098bdbcdb480e4

See more details on using hashes here.

File details

Details for the file pydantic_resolve-4.0.0a1-py3-none-any.whl.

File metadata

  • Download URL: pydantic_resolve-4.0.0a1-py3-none-any.whl
  • Upload date:
  • Size: 107.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.2 {"installer":{"name":"uv","version":"0.11.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for pydantic_resolve-4.0.0a1-py3-none-any.whl
Algorithm Hash digest
SHA256 087fcd8d3de8211c9e6203b77f638fc39b6c9690d668ff362c2e5606bb48ccdb
MD5 22a9393dc9347a9478fb166e0e7fdf03
BLAKE2b-256 1951b66f0eadaa8d9d73456365d48407a9bf21cc7bf9e56da6a7bff753fc542c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page