1. なぜ SQLModel?(超要約)
- SQLModel = Pydantic + SQLAlchemy の良いとこ取り
- モデル1つで バリデーション(スキーマ) と DBテーブル(ORM) を兼用
- 型安全に書けて、SQLAlchemy の強いクエリ機構もそのまま使える
メモ: SQLModel の最新版は Pydantic v2 に対応。もし v1 系を使っている環境なら、本文の
model_dump()をdict()に読み替えればOK。
2. 検証環境とセットアップ
環境
- Windows / PowerShell
- Python 3.11 以上(想定)
パッケージ
-
fastapi,sqlmodel,uvicorn[standard]
初期化
mkdir fastapi-sqlmodel-demo
cd fastapi-sqlmodel-demo
uv init
uv add fastapi sqlmodel "uvicorn[standard]"
3. Step 0: FastAPI の最小起動(/ping)
app.py
from fastapi import FastAPI
app = FastAPI()
# ヘルスチェック用の最小エンドポイント
@app.get("/ping")
def ping():
# FastAPI は dict を返すと JSON に変換して返してくれる
return {"status": "ok"}
起動:
uv run uvicorn app:app --reload
- ブラウザで
http://127.0.0.1:8000/pingにアクセス →{ "status": "ok" }
-
http://127.0.0.1:8000/docs→ 自動ドキュメント
4. Step 1: 単一ファイルで CRUD を完成(SQLModel × SQLite)
まずは 1ファイルで全体像を掴む。
※初心者にもわかりやすいようにコード内に解説コメントを多めに書いています。
app.py を丸ごと置き換え:
# app.py(単一ファイルで CRUD まで)
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException, status
from sqlmodel import SQLModel, Field, Session, create_engine, select
# --- DB接続の設定 ---
# SQLite の DB ファイル。相対パスなので「uv を実行したカレントディレクトリ」に作成されます。
# 学習時は echo=True で発行される SQL を観察できるようにしています(実運用では False 推奨)。
sqlite_url = "sqlite:///app.db"
engine = create_engine(sqlite_url, echo=True)
# アプリ起動時にテーブルを作成(簡易運用)
# 本格運用では Alembic 等のマイグレーションを使うのが定石です。
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
# リクエストごとに新しい Session を提供する依存関係
# `Depends(get_session)` と書いたエンドポイントにセッションが注入され、処理後に必ずクローズされます。
def get_session():
with Session(engine) as session:
yield session
# --- モデル定義(SQLModel) ---
# TaskBase は共通フィールドの“型”と“制約”をまとめるための基底クラス
class TaskBase(SQLModel):
# index=True で検索に使うカラムにインデックスを張る(SQLite でも有効)
# min_length/max_length は Pydantic 側のバリデーション(DB 側の長さ制約ではない点に注意)
title: str = Field(index=True, min_length=1, max_length=200)
# 既定値 False(未指定なら False が入る)
done: bool = False
# table=True を付けると「テーブル(ORM)」として扱われる
class Task(TaskBase, table=True):
# 主キー。Auto Increment。INSERT 後に採番される
id: Optional[int] = Field(default=None, primary_key=True)
# 入力(作成時)用スキーマ。TaskBase をそのまま使う
class TaskCreate(TaskBase):
pass
# 出力(読み取り)用スキーマ。API レスポンスに id を含めたいので別定義
class TaskRead(TaskBase):
id: int
# 更新(部分更新)用スキーマ。すべて任意にして PATCH に対応
class TaskUpdate(SQLModel):
title: Optional[str] = None
done: Optional[bool] = None
# --- FastAPI 本体 ---
app = FastAPI()
# アプリ起動時に一度だけテーブルを作成
@app.on_event("startup")
def on_startup():
create_db_and_tables()
# 動作確認用
@app.get("/ping")
def ping():
return {"status": "ok"}
# --- CRUD エンドポイント ---
# Create: 1件作成
@app.post("/tasks", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
def create_task(payload: TaskCreate, session: Session = Depends(get_session)):
# Pydantic v2: model_dump() / v1: dict()
task = Task(**payload.model_dump())
session.add(task) # まずはトランザクションに追加
session.commit() # ここで実際に INSERT 発行&確定
session.refresh(task) # DB 側で決まった値(id など)を Python オブジェクトに反映
return task
# Read: 一覧取得
@app.get("/tasks", response_model=list[TaskRead])
def list_tasks(session: Session = Depends(get_session)):
# select(Task) で SELECT * FROM task 相当のクエリを構築
return session.exec(select(Task)).all()
# Read: 1件取得(主キー)
@app.get("/tasks/{task_id}", response_model=TaskRead)
def get_task(task_id: int, session: Session = Depends(get_session)):
# 主キー検索の最短ルート。見つからなければ None
task = session.get(Task, task_id)
if not task:
# FastAPI 標準の例外 → 適切な HTTP ステータス/JSON に変換される
raise HTTPException(status_code=404, detail="Task not found")
return task
# Update: 部分更新(PATCH)
@app.patch("/tasks/{task_id}", response_model=TaskRead)
def update_task(task_id: int, patch: TaskUpdate, session: Session = Depends(get_session)):
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# 送られてきた項目だけを取り出す。未指定フィールドの意図しない上書きを防ぐ。
changes = patch.model_dump(exclude_unset=True) # v1: dict(exclude_unset=True)
for k, v in changes.items():
setattr(task, k, v)
session.add(task)
session.commit() # UPDATE を確定
session.refresh(task) # 反映後の最新状態を再取得
return task
# Delete: 削除
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, session: Session = Depends(get_session)):
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
session.delete(task)
session.commit() # DELETE を確定
実行:
uv run uvicorn app:app --reload
- ブラウザで
http://127.0.0.1:8000/docsにアクセス
-
POST /tasks から
Try it out→ POST に {"title":"first task"} を入れてExecuteで送信 -
続けて GET /tasks で
Execute一覧が返るのを確認 -
同様に PACH /tasks , DELETE /tasks も確認
ポイント
-
Depends(get_session)でセッションをリクエストごとに供給 -
commit()→refresh()で DB 確定値(採番 ID 等)を反映 -
exclude_unset=Trueで 部分更新 を安全に(未指定フィールドの上書き防止) - 404/201/204 など 適切なHTTPステータス を返す
CRUDの動き(解説)
POST /tasks(作成)
- 送信した
titleで新しい行を作成し、commit()で保存。refresh()で採番IDがオブジェクトに戻ります。 - 例:
{"title": "first task"}
{"id": 1, "title": "first task", "done": false}
GET /tasks(一覧)
- 全ての
Taskを配列で返します。
[
{"id": 1, "title": "first task", "done": false}
]
GET /tasks/{id}(1件取得)
-
session.get(Task, id)で主キー検索。無いときは 404 を返します。
PATCH /tasks/{id}(部分更新)
- 送られた項目だけを更新(
exclude_unset=True)。
{"done": true}
{"id": 1, "title": "first task", "done": true}
DELETE /tasks/{id}(削除)
- 成功すると 204 No Content(本文なし)。
補足:
Depends(get_session)で各リクエストに新しい DB セッションが渡され、処理後に自動でクローズされます。
5. Step 2: ファイル分割(db / models / schemas / main)
単一ファイルから 責務ごとの分割 へ。
まずはパッケージ構成に分割して「起動できる」状態まで作ります(この段階では /tasks はまだ追加しません)。そのあとで Repository と Router を足します。
app/
__init__.py
main.py # FastAPI本体(/ping など)
db.py # Engine, Session依存性, 初期化
models.py # SQLModel のテーブル定義
schemas.py # 入出力用スキーマ(Create/Read/Update)
app/db.py
from sqlmodel import SQLModel, Session, create_engine
# SQLite の DB ファイル。相対パスは実行カレントに依存します。
# 学習中は echo=True で実行される SQL を観察。実運用では False 推奨。
sqlite_url = "sqlite:///app.db"
engine = create_engine(sqlite_url, echo=True)
# アプリ起動時にテーブルを一括作成(簡易運用向け)
# 変更が増えてきたら Alembic などでマイグレーション管理に切替えましょう。
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
# FastAPI の Depends で使うセッション供給関数。
# with ブロックにより、処理の成否に関わらず確実にクローズされます。
def get_session():
with Session(engine) as session:
yield session
app/models.py
from typing import Optional
from sqlmodel import SQLModel, Field
# テーブル定義(ORM)
class Task(SQLModel, table=True):
# 主キー。None のまま add→commit すると DB 側で採番される
id: Optional[int] = Field(default=None, primary_key=True)
# 検索に使う title にインデックスを付与。
# min_length/max_length は Pydantic の入力バリデーション(DB の制約ではない点に注意)。
title: str = Field(index=True, min_length=1, max_length=200)
# 完了フラグ(既定 False)
done: bool = False
app/schemas.py
from typing import Optional
from sqlmodel import SQLModel
# 入出力用スキーマを役割ごとに分けて、API の I/O を明確にする
class TaskBase(SQLModel):
# 入力時のバリデーションも兼ねる
title: str
done: bool = False
# 作成時の入力(title 必須 / done 任意)
class TaskCreate(TaskBase):
pass
# レスポンス用(id を含める)
class TaskRead(TaskBase):
id: int
# 部分更新用。すべて Optional にして PATCH を安全に扱う
class TaskUpdate(SQLModel):
title: Optional[str] = None
done: Optional[bool] = None
app/main.py
from fastapi import FastAPI
from .db import create_db_and_tables
# アプリケーション本体
app = FastAPI()
# 起動時に一度だけテーブル作成(簡易運用)
@app.on_event("startup")
def on_startup():
create_db_and_tables()
# 動作確認用の最小エンドポイント
@app.get("/ping")
def ping():
return {"status": "ok"}
起動:
uv run uvicorn app.main:app --reload
6. Step 3: Repository + Router でレイヤ分割(DDD的な責務分離)
HTTP の処理(Router)と DB 操作(Repository)を分けると、責務が明確になりテストしやすくなります。ここではページングなどの発展機能は入れず、まずは最小の CRUD を分離します。
構成
app/
repositories/
__init__.py
tasks.py
routers/
__init__.py
tasks.py
db.py
models.py
schemas.py
main.py
app/repositories/tasks.py
from typing import Optional
from sqlmodel import Session, select
from ..models import Task
from ..schemas import TaskCreate, TaskUpdate
# Repository 層:DB 操作だけを集約(HTTP を一切知らない)
def create(session: Session, data: TaskCreate) -> Task:
task = Task(**data.model_dump()) # Pydantic v1 は data.dict()
session.add(task)
session.commit()
session.refresh(task)
return task
# 最小の一覧:まずは全件(必要になったら WHERE や ORDER BY を追加)
def list_all(session: Session) -> list[Task]:
return session.exec(select(Task)).all()
# 主キー取得
def get_by_id(session: Session, task_id: int) -> Optional[Task]:
return session.get(Task, task_id)
# 部分更新(未指定フィールドの上書きを避ける)
def update(session: Session, task_id: int, patch: TaskUpdate) -> Optional[Task]:
task = session.get(Task, task_id)
if not task:
return None
for k, v in patch.model_dump(exclude_unset=True).items(): # v1: dict(exclude_unset=True)
setattr(task, k, v)
session.add(task)
session.commit()
session.refresh(task)
return task
# 削除
def delete(session: Session, task_id: int) -> bool:
task = session.get(Task, task_id)
if not task:
return False
session.delete(task)
session.commit()
return True
app/routers/tasks.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session
from ..db import get_session
from ..schemas import TaskCreate, TaskRead, TaskUpdate
from ..repositories import tasks as repo
# Router 層:HTTP の入出力とステータスコードの責務を担当
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post("", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
def create_task(payload: TaskCreate, session: Session = Depends(get_session)):
return repo.create(session, payload)
@router.get("", response_model=list[TaskRead])
def list_tasks(session: Session = Depends(get_session)):
return repo.list_all(session)
@router.get("/{task_id}", response_model=TaskRead)
def get_task(task_id: int, session: Session = Depends(get_session)):
task = repo.get_by_id(session, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.patch("/{task_id}", response_model=TaskRead)
def update_task(task_id: int, patch: TaskUpdate, session: Session = Depends(get_session)):
task = repo.update(session, task_id, patch)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, session: Session = Depends(get_session)):
ok = repo.delete(session, task_id)
if not ok:
raise HTTPException(status_code=404, detail="Task not found")
app/main.py(ルーターを組み込む)
from fastapi import FastAPI
from .db import create_db_and_tables
from .routers.tasks import router as tasks_router
app = FastAPI()
@app.on_event("startup")
def on_startup():
create_db_and_tables()
@app.get("/ping")
def ping():
return {"status": "ok"}
# 追加:/tasks ルーターを有効化
app.include_router(tasks_router)
ポイント:
repositories/とrouters/に__init__.pyを置く(Python のパッケージとして認識させる)。
7. よくあるハマりどころ
-
model_dump()が無い ⇒ Pydantic v1 系。obj.dict()/dict(exclude_unset=True)を使う -
SQLite ファイルが見つからない ⇒
sqlite:///相対パスは「実行カレント」に依存。uv runをプロジェクト直下で -
ログがうるさい ⇒
create_engine(..., echo=False)に変更 -
CORS ⇒ フロントと別ドメインなら
fastapi.middleware.corsで許可設定
8. 次の一歩(拡張アイデア)
-
ページング: (
limit/offset)、一覧の強化: 並び替え(ORDER BY)、検索(title部分一致)、合計件数(total) -
モデルの堅牢化: ユニーク制約、
created_at/updated_at、IntegrityErrorハンドリング -
リレーション:
Project–Task(1対多)を定義し、/projects/{id}/tasksを追加
9. まとめ
- 単一ファイル CRUD で挙動を理解 → 責務分割 で拡張性UP
- SQLModel は Pydantic の型安全 × SQLAlchemy の表現力で 実装スピードと保守性 を両立
- この土台に、検索/並び替え/認証/リレーション等を積み上げれば実用APIへ


