jpskill.com
🛠️ 開発・MCP コミュニティ

py-sqlmodel-patterns

SQLModel and async SQLAlchemy patterns. Use when working with database models, queries, relationships, or debugging ORM issues.

⚡ おすすめ: コマンド1行でインストール(60秒)

下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。

🍎 Mac / 🐧 Linux
mkdir -p ~/.claude/skills && cd ~/.claude/skills && curl -L -o py-sqlmodel-patterns.zip https://jpskill.com/download/17847.zip && unzip -o py-sqlmodel-patterns.zip && rm py-sqlmodel-patterns.zip
🪟 Windows (PowerShell)
$d = "$env:USERPROFILE\.claude\skills"; ni -Force -ItemType Directory $d | Out-Null; iwr https://jpskill.com/download/17847.zip -OutFile "$d\py-sqlmodel-patterns.zip"; Expand-Archive "$d\py-sqlmodel-patterns.zip" -DestinationPath $d -Force; ri "$d\py-sqlmodel-patterns.zip"

完了後、Claude Code を再起動 → 普通に「動画プロンプト作って」のように話しかけるだけで自動発動します。

💾 手動でダウンロードしたい(コマンドが難しい人向け)
  1. 1. 下の青いボタンを押して py-sqlmodel-patterns.zip をダウンロード
  2. 2. ZIPファイルをダブルクリックで解凍 → py-sqlmodel-patterns フォルダができる
  3. 3. そのフォルダを C:\Users\あなたの名前\.claude\skills\(Win)または ~/.claude/skills/(Mac)へ移動
  4. 4. Claude Code を再起動

⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。

🎯 このSkillでできること

下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。

📦 インストール方法 (3ステップ)

  1. 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
  2. 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
  3. 3. 展開してできたフォルダを、ホームフォルダの .claude/skills/ に置く
    • · macOS / Linux: ~/.claude/skills/
    • · Windows: %USERPROFILE%\.claude\skills\

Claude Code を再起動すれば完了。「このSkillを使って…」と話しかけなくても、関連する依頼で自動的に呼び出されます。

詳しい使い方ガイドを見る →
最終更新
2026-05-18
取得日時
2026-05-18
同梱ファイル
1

📖 Skill本文(日本語訳)

※ 原文(英語/中国語)を Gemini で日本語化したものです。Claude 自身は原文を読みます。誤訳がある場合は原文をご確認ください。

SQLModel のパターン

問題提起

SQLModel は Pydantic と SQLAlchemy を組み合わせることで、モデルとスキーマの境界線を曖昧にしています。Async SQLAlchemy は sync とは異なるルールを持っています。ここで間違いを犯すと、データの破損、N+1 クエリ、デバッグが困難なエラーが発生する可能性があります。


パターン: Async のための Eager Loading

問題: Lazy loading は async SQLAlchemy では動作しません。Eager loading なしに関係にアクセスすると、エラーが発生します。

# ❌ 間違い: Lazy loading は async では失敗する
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one()
assessments = user.assessments  # エラー: greenlet_spawn が呼び出されていません

# ✅ 正しい: コレクションのための selectinload
from sqlalchemy.orm import selectinload

result = await session.execute(
    select(User)
    .where(User.id == user_id)
    .options(selectinload(User.assessments))
)
user = result.scalar_one()
assessments = user.assessments  # 動作します - すでにロード済み

# ✅ 正しい: 単一の関係のための joinedload
from sqlalchemy.orm import joinedload

result = await session.execute(
    select(Assessment)
    .where(Assessment.id == assessment_id)
    .options(joinedload(Assessment.user))
)
assessment = result.scalar_one()
user = assessment.user  # 動作します - すでにロード済み

いつどちらを使うか:

関係性 ローディング戦略
One-to-many (コレクション) selectinload()
Many-to-one (単一) joinedload()
ネストされた関係 チェーン: .options(selectinload(A.b).selectinload(B.c))

パターン: N+1 クエリの検出

問題: 関連オブジェクトをまとめてではなく、1 つずつフェッチすること。

# ❌ 間違い: N+1 クエリ
users = await session.execute(select(User))
for user in users.scalars():
    # アクセスごとにクエリがトリガーされます!
    print(user.team.name)  # クエリ 1, 2, 3... N

# ✅ 正しい: Eager loading を使用した単一のクエリ
users = await session.execute(
    select(User).options(joinedload(User.team))
)
for user in users.scalars():
    print(user.team.name)  # 追加のクエリはありません

# 検出: 開発環境で SQL echo を有効にする
engine = create_async_engine(DATABASE_URL, echo=True)
# ログで繰り返される同様のクエリを監視します

パターン: モデルとスキーマの分離

問題: SQLModel はモデル (DB) とスキーマ (API) を曖昧にします。明確な分離が必要です。

# データベースモデル - テーブルを表す
class User(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    email: str = Field(index=True, unique=True)
    hashed_password: str  # これを公開しないでください
    created_at: datetime = Field(default_factory=datetime.utcnow)

    # 関係性
    assessments: list["Assessment"] = Relationship(back_populates="user")

# API スキーマ - 作成 (入力)
class UserCreate(SQLModel):
    email: str
    password: str  # プレーンなパスワード、ハッシュ化されます

# API スキーマ - 読み取り (出力)
class UserRead(SQLModel):
    id: UUID
    email: str
    created_at: datetime
    # 注: パスワードフィールドはありません!

# API スキーマ - 更新 (部分)
class UserUpdate(SQLModel):
    email: str | None = None
    password: str | None = None

命名規則:

  • ModelName - データベーステーブルモデル
  • ModelNameCreate - 作成のための入力
  • ModelNameRead - 読み取りのための出力
  • ModelNameUpdate - 部分的な更新のための入力

パターン: セッション状態の管理

問題: expire_on_commit とオブジェクトがいつ古くなるかを理解すること。

# このコードベースの設定
async_session = async_sessionmaker(
    engine,
    expire_on_commit=False,  # オブジェクトはコミット後も有効なまま
)

# expire_on_commit=False の場合:
user = User(email="test@example.com")
session.add(user)
await session.commit()
print(user.email)  # 動作します - オブジェクトはまだ有効です

# expire_on_commit=True (デフォルト) の場合:
await session.commit()
print(user.email)  # 最初に refresh() が必要になります

# ✅ 正しい: DB によって生成された値が必要な場合はリフレッシュします
await session.commit()
await session.refresh(user)  # id, created_at, 更新された DB の値を取得します
return user

パターン: UUID の処理

問題: Python と PostgreSQL 間での一貫性のない UUID 処理。

from uuid import UUID, uuid4

# ✅ 正しい: デフォルトファクトリを持つ UUID
class Assessment(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    user_id: UUID = Field(foreign_key="user.id")

# ✅ 正しい: クエリ内の UUID
await session.execute(
    select(Assessment).where(Assessment.id == UUID("..."))
)

# ❌ 間違い: 文字列比較
await session.execute(
    select(Assessment).where(Assessment.id == "some-uuid-string")
)

# ✅ 正しい: API レイヤーでの変換
@router.get("/assessments/{assessment_id}")
async def get_assessment(assessment_id: UUID):  # FastAPI は文字列を UUID に変換します
    ...

パターン: Nullable なフィールド

問題: SQLModel では、オプションのフィールドに特定の構文が必要です。

# ✅ 正しい: None デフォルトを持つオプションのフィールド
class Assessment(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    title: str  # 必須
    description: str | None = Field(default=None)  # オプション
    completed_at: datetime | None = Field(default=None)  # オプション

    # オプションの外部キー
    coach_id: UUID | None = Field(default=None, foreign_key="user.id")

# ❌ 間違い: Field デフォルトなしのオプション
class BadModel(SQLModel, table=True):
    description: str | None  # デフォルトがありません - 問題が発生します

パターン: 関係の定義


from sqlmodel import Relationship

class User(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)

    # One-to-many: User は多くのアセスメントを持つ
    assessments: list["Assessment"] = Relationship(back_populates="user")

    # One-to-many: User は多くの回答を持つ
    answers: list["UserAnswer"] = Relationship(back_populates="user")

class Assessment(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    user_id: UUID = Field(foreign_key="user.id")



(原文はここで切り詰められています)
📜 原文 SKILL.md(Claudeが読む英語/中国語)を展開

SQLModel Patterns

Problem Statement

SQLModel combines Pydantic and SQLAlchemy, blurring the line between models and schemas. Async SQLAlchemy has different rules than sync. Mistakes here cause data corruption, N+1 queries, and hard-to-debug errors.


Pattern: Eager Loading for Async

Problem: Lazy loading doesn't work with async SQLAlchemy. Accessing relationships without eager loading raises errors.

# ❌ WRONG: Lazy loading fails in async
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one()
assessments = user.assessments  # ERROR: greenlet_spawn has not been called

# ✅ CORRECT: selectinload for collections
from sqlalchemy.orm import selectinload

result = await session.execute(
    select(User)
    .where(User.id == user_id)
    .options(selectinload(User.assessments))
)
user = result.scalar_one()
assessments = user.assessments  # Works - already loaded

# ✅ CORRECT: joinedload for single relationships
from sqlalchemy.orm import joinedload

result = await session.execute(
    select(Assessment)
    .where(Assessment.id == assessment_id)
    .options(joinedload(Assessment.user))
)
assessment = result.scalar_one()
user = assessment.user  # Works - already loaded

When to use which:

Relationship Loading Strategy
One-to-many (collections) selectinload()
Many-to-one (single) joinedload()
Nested relationships Chain: .options(selectinload(A.b).selectinload(B.c))

Pattern: N+1 Query Detection

Problem: Fetching related objects one-by-one instead of in batch.

# ❌ WRONG: N+1 queries
users = await session.execute(select(User))
for user in users.scalars():
    # Each access triggers a query!
    print(user.team.name)  # Query 1, 2, 3... N

# ✅ CORRECT: Single query with eager loading
users = await session.execute(
    select(User).options(joinedload(User.team))
)
for user in users.scalars():
    print(user.team.name)  # No additional queries

# Detection: Enable SQL echo in development
engine = create_async_engine(DATABASE_URL, echo=True)
# Watch logs for repeated similar queries

Pattern: Model vs Schema Separation

Problem: SQLModel blurs models (DB) and schemas (API). Need clear separation.

# Database Model - represents table
class User(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    email: str = Field(index=True, unique=True)
    hashed_password: str  # Never expose this
    created_at: datetime = Field(default_factory=datetime.utcnow)

    # Relationships
    assessments: list["Assessment"] = Relationship(back_populates="user")

# API Schema - Create (input)
class UserCreate(SQLModel):
    email: str
    password: str  # Plain password, will be hashed

# API Schema - Read (output)
class UserRead(SQLModel):
    id: UUID
    email: str
    created_at: datetime
    # Note: No password field!

# API Schema - Update (partial)
class UserUpdate(SQLModel):
    email: str | None = None
    password: str | None = None

Naming convention:

  • ModelName - Database table model
  • ModelNameCreate - Input for creation
  • ModelNameRead - Output for reading
  • ModelNameUpdate - Input for partial updates

Pattern: Session State Management

Problem: Understanding expire_on_commit and when objects become stale.

# This codebase setting
async_session = async_sessionmaker(
    engine,
    expire_on_commit=False,  # Objects stay valid after commit
)

# With expire_on_commit=False:
user = User(email="test@example.com")
session.add(user)
await session.commit()
print(user.email)  # Works - object still valid

# With expire_on_commit=True (default):
await session.commit()
print(user.email)  # Would need refresh() first

# ✅ CORRECT: Refresh when you need DB-generated values
await session.commit()
await session.refresh(user)  # Get id, created_at, updated DB values
return user

Pattern: UUID Handling

Problem: Inconsistent UUID handling between Python and PostgreSQL.

from uuid import UUID, uuid4

# ✅ CORRECT: UUID with default factory
class Assessment(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    user_id: UUID = Field(foreign_key="user.id")

# ✅ CORRECT: UUID in queries
await session.execute(
    select(Assessment).where(Assessment.id == UUID("..."))
)

# ❌ WRONG: String comparison
await session.execute(
    select(Assessment).where(Assessment.id == "some-uuid-string")
)

# ✅ CORRECT: Converting in API layer
@router.get("/assessments/{assessment_id}")
async def get_assessment(assessment_id: UUID):  # FastAPI converts string to UUID
    ...

Pattern: Nullable Fields

Problem: SQLModel requires specific syntax for optional fields.

# ✅ CORRECT: Optional field with None default
class Assessment(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    title: str  # Required
    description: str | None = Field(default=None)  # Optional
    completed_at: datetime | None = Field(default=None)  # Optional

    # Foreign key that's optional
    coach_id: UUID | None = Field(default=None, foreign_key="user.id")

# ❌ WRONG: Optional without Field default
class BadModel(SQLModel, table=True):
    description: str | None  # Missing default - causes issues

Pattern: Relationship Definitions

from sqlmodel import Relationship

class User(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)

    # One-to-many: User has many assessments
    assessments: list["Assessment"] = Relationship(back_populates="user")

    # One-to-many: User has many answers
    answers: list["UserAnswer"] = Relationship(back_populates="user")

class Assessment(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    user_id: UUID = Field(foreign_key="user.id")

    # Many-to-one: Assessment belongs to user
    user: User = Relationship(back_populates="assessments")

    # One-to-many: Assessment has many questions
    questions: list["Question"] = Relationship(back_populates="assessment")

class Question(SQLModel, table=True):
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    assessment_id: UUID = Field(foreign_key="assessment.id")

    # Many-to-one
    assessment: Assessment = Relationship(back_populates="questions")

Pattern: Query Patterns

# Get one or None
result = await session.execute(
    select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()

# Get one or raise
user = result.scalar_one()  # Raises if 0 or >1 results

# Get list
result = await session.execute(
    select(Assessment).where(Assessment.user_id == user_id)
)
assessments = result.scalars().all()

# Get with pagination
result = await session.execute(
    select(Assessment)
    .where(Assessment.user_id == user_id)
    .order_by(Assessment.created_at.desc())
    .offset(skip)
    .limit(limit)
)

# Count
result = await session.execute(
    select(func.count()).select_from(Assessment).where(...)
)
count = result.scalar_one()

# Exists check
result = await session.execute(
    select(exists().where(User.email == email))
)
email_exists = result.scalar()

Pattern: Upsert (Insert or Update)

from sqlalchemy.dialects.postgresql import insert

# ✅ CORRECT: PostgreSQL upsert
stmt = insert(UserAnswer).values(
    user_id=user_id,
    question_id=question_id,
    value=value,
)
stmt = stmt.on_conflict_do_update(
    index_elements=["user_id", "question_id"],
    set_={"value": value, "updated_at": datetime.utcnow()},
)
await session.execute(stmt)
await session.commit()

References


Common Issues

Issue Likely Cause Solution
"greenlet_spawn has not been called" Lazy loading in async Use selectinload/joinedload
N+1 queries (slow) Missing eager loading Add appropriate loading strategy
"Object not bound to session" Using object after session closed Keep operations within session scope
Stale data Missing refresh() Call refresh() after commit
"None is not valid" for UUID Missing default_factory Add Field(default_factory=uuid4)

Detection Commands

# Find lazy relationship access
grep -rn "\.scalars\(\)" --include="*.py" -A5 | grep -E "\.\w+\s*$"

# Find models missing relationship loading
grep -rn "select(" --include="*.py" | grep -v "options("

# Check for N+1 in logs (with echo=True)
# Look for repeated similar queries