SQLAlchemy 2 ORM rows serialized with Pydantic v2 from_attributes: working versions (tested 2026-10-15)
Map SQLAlchemy 2 models, expose nested relationships through Pydantic v2 schemas with from_attributes=True and a computed_field, and test them offline against in-memory SQLite.
passed4 passed · 0 failed2026-10-15 11:57 UTC- Status
- passed
- Last passed
- 2026-10-15 11:57 UTC
- Tests
- 4 / 4
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38730348195
The code
Exactly the files that passed. Your agent can pull them with get_recipe("sqlalchemy2-pydantic-v2-from-attributes") (install), or see them on GitHub.
sqlalchemy==2.1.4 pydantic==2.14.1 pytest==9.1.1
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
books: Mapped[list["Book"]] = relationship(back_populates="author", order_by="Book.id")
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
author: Mapped["Author"] = relationship(back_populates="books")
from pydantic import BaseModel, ConfigDict, computed_field
class OrmModel(BaseModel):
# Without this, model_validate(orm_row) raises ValidationError.
model_config = ConfigDict(from_attributes=True)
class BookOut(OrmModel):
id: int
title: str
class AuthorOut(OrmModel):
id: int
name: str
books: list[BookOut]
@computed_field
@property
def book_count(self) -> int:
return len(self.books)
import pytest
from pydantic import BaseModel, ValidationError
from sqlalchemy import create_engine, select
from sqlalchemy.orm import selectinload, sessionmaker
from sqlalchemy.pool import StaticPool
from models import Author, Base, Book
from schemas import AuthorOut
class PlainAuthor(BaseModel):
id: int
name: str
@pytest.fixture
def session_factory():
engine = create_engine(
"sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False}
)
Base.metadata.create_all(engine)
factory = sessionmaker(engine, expire_on_commit=False)
with factory.begin() as s:
s.add(Author(name="Ursula", books=[Book(title="A"), Book(title="B")]))
s.add(Author(name="Solo"))
yield factory
engine.dispose()
def test_nested_rows_validate_after_session_closes(session_factory):
with session_factory() as s:
author = s.scalars(
select(Author).where(Author.name == "Ursula").options(selectinload(Author.books))
).one()
out = AuthorOut.model_validate(author)
assert [b.title for b in out.books] == ["A", "B"]
assert out.book_count == 2
def test_unloaded_relationship_fails_after_close(session_factory):
with session_factory() as s:
author = s.scalars(select(Author).where(Author.name == "Ursula")).one()
# books was never eager loaded. Validation touches it on a detached row, and
# Pydantic wraps the DetachedInstanceError in a ValidationError.
with pytest.raises(ValidationError, match="not bound to a Session"):
AuthorOut.model_validate(author)
def test_from_attributes_is_opt_in(session_factory):
with session_factory() as s:
author = s.scalars(select(Author).where(Author.name == "Solo")).one()
with pytest.raises(ValidationError):
PlainAuthor.model_validate(author)
assert PlainAuthor.model_validate(author, from_attributes=True).name == "Solo"
def test_computed_field_is_output_only(session_factory):
with session_factory() as s:
author = s.scalars(
select(Author).where(Author.name == "Ursula").options(selectinload(Author.books))
).one()
out = AuthorOut.model_validate(author)
assert out.model_dump()["book_count"] == 2
# book_count supplied as input is ignored; it is always derived from books
fake = AuthorOut.model_validate({"id": 1, "name": "x", "books": [], "book_count": 99})
assert fake.book_count == 0
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Alembic autogenerate compare_metadata drift check against SQLite
Declare SQLAlchemy 2 models with a naming convention, then detect schema drift offline by diffing the live SQLite schema against the metadata with Alembic compare_metadata.
Async repository pattern with SQLAlchemy 2 AsyncSession on aiosqlite
A typed async repository over SQLAlchemy 2 AsyncSession with eager loading, foreign keys enforced on SQLite, and offline pytest-asyncio tests against an in-memory database.
FastAPI + Pydantic v2 validators, discriminated unions and serializers
Pydantic v2 patterns in FastAPI that agents often get wrong: field_validator, model_validator, discriminated unions, computed_field and response_model filtering.
FastAPI + SQLAlchemy 2 CRUD with dependency-injected sessions
FastAPI routes backed by SQLAlchemy 2.x typed models, a per-request session dependency, and TestClient tests on SQLite.