SQLModel one-to-many and many-to-many relationships tested on in-memory SQLite: working versions (tested 2026-10-15)
Model one-to-many and link-table many-to-many relationships with SQLModel, load them with selectinload, and test offline on in-memory SQLite with StaticPool.
passed5 passed · 0 failed2026-10-15 13:18 UTC- Status
- passed
- Last passed
- 2026-10-15 13:18 UTC
- Tests
- 5 / 5
- 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 38740169402
The code
Exactly the files that passed. Your agent can pull them with get_recipe("sqlmodel-relationships-sqlite") (install), or see them on GitHub.
sqlmodel==0.0.48 pytest==9.1.1
from sqlalchemy import event
from sqlalchemy.pool import StaticPool
from sqlmodel import Field, Relationship, SQLModel, create_engine
class HeroMissionLink(SQLModel, table=True):
hero_id: int | None = Field(default=None, foreign_key="hero.id", primary_key=True)
mission_id: int | None = Field(default=None, foreign_key="mission.id", primary_key=True)
class Team(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(unique=True)
heroes: list["Hero"] = Relationship(back_populates="team")
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
team_id: int | None = Field(default=None, foreign_key="team.id")
team: Team | None = Relationship(back_populates="heroes")
missions: list["Mission"] = Relationship(back_populates="heroes", link_model=HeroMissionLink)
class Mission(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
title: str
heroes: list[Hero] = Relationship(back_populates="missions", link_model=HeroMissionLink)
def make_engine():
# One shared connection so every session sees the same in-memory database.
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
# SQLite ignores foreign keys unless this pragma is set per connection.
@event.listens_for(engine, "connect")
def _enable_fks(dbapi_conn, _record):
cur = dbapi_conn.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
SQLModel.metadata.create_all(engine)
return engine
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.exc import DetachedInstanceError
from sqlmodel import Session, select
from models import Hero, HeroMissionLink, Mission, Team, make_engine
@pytest.fixture
def engine():
eng = make_engine()
yield eng
eng.dispose()
def test_back_populates_syncs_both_sides(engine):
with Session(engine) as s:
team = Team(name="avengers")
hero = Hero(name="Thor", team=team)
s.add(hero)
# No flush or query needed: back_populates keeps the in-memory lists in sync.
assert hero.team is team
assert team.heroes == [hero]
s.commit()
def test_selectinload_keeps_children_after_close(engine):
with Session(engine) as s:
s.add(Team(name="x", heroes=[Hero(name="a"), Hero(name="b")]))
s.commit()
with Session(engine) as s:
team = s.exec(select(Team).options(selectinload(Team.heroes))).one()
# The session is closed, but the children were loaded up front.
assert sorted(h.name for h in team.heroes) == ["a", "b"]
def test_lazy_access_after_close_raises(engine):
with Session(engine) as s:
s.add(Team(name="y", heroes=[Hero(name="c")]))
s.commit()
with Session(engine) as s:
team = s.exec(select(Team)).one()
with pytest.raises(DetachedInstanceError):
team.heroes
def test_many_to_many_via_link_table(engine):
with Session(engine) as s:
m1, m2 = Mission(title="m1"), Mission(title="m2")
s.add_all([Hero(name="Thor", missions=[m1, m2]), Hero(name="Hulk", missions=[m1])])
s.commit()
with Session(engine) as s:
mission = s.exec(select(Mission).where(Mission.title == "m1")).one()
assert sorted(h.name for h in mission.heroes) == ["Hulk", "Thor"]
assert len(s.exec(select(HeroMissionLink)).all()) == 3
def test_foreign_keys_enforced(engine):
with Session(engine) as s:
s.add(Hero(name="ghost", team_id=999))
with pytest.raises(IntegrityError):
s.commit()
[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 + 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.
FastAPI with async SQLAlchemy 2 on aiosqlite, tested offline with per-test in-memory DBs
A FastAPI app using AsyncSession and async_sessionmaker on aiosqlite, tested with httpx.AsyncClient over ASGITransport, overriding the session dependency with a fresh in-memory database for each test.