32 人が閲覧(直近 30 日) Python

FastAPIのベストプラクティス|ディレクトリ構成・SQLAlchemy 2.1の設計・テストを実測で解説

FastAPIのベストプラクティス|ディレクトリ構成・SQLAlchemy 2.1の設計・テストを実測で解説

FastAPIでデータベースを扱うアプリを書き始めると、「ファイルをどう分けるか」「セッションをどこで開いてどこでcommitするか」「テストでDBをどう差し替えるか」で手が止まります。この記事は、FastAPIとSQLAlchemyを組み合わせた実務向けの構成を、ディレクトリ構成からマイグレーション・テストまで1つの型として示します。掲載したコードは2026年10月15日に、FastAPI 0.143.1・SQLAlchemy 2.1.4・Pydantic 2.14.1・Python 3.14.6・PostgreSQL 18.6の組み合わせで実行し、pytestとAlembicまで通したものです。

まとめ:FastAPIベストプラクティスの要点

  • ディレクトリは層で分ける:models・schemas・repositories・services・apiに分け、ドメインが増えて1つのディレクトリに別機能のファイルが混ざり始めたらドメイン単位へ移す。
  • commitはサービス層で呼ぶ:yieldの後にcommitを書くと、commitが失敗してもクライアントには200が返る(実測)。
  • 同期のSQLAlchemyならエンドポイントはdef:async defの中で同期セッションを呼ぶと、0.5秒のクエリ10本の同時実行に5.13秒かかった(defは0.58秒)。
  • SQLAlchemy 2.1の変更を前提にする:Python 3.11以上が必要で、postgresql://の既定ドライバがpsycopg 3になり、非同期用のgreenletはsqlalchemy[asyncio]で入れる。
  • 制約名の規則を最初に入れる:naming_conventionを設定したBaseを使い、Alembicの自動生成で制約名を固定する。
  • テストはdependency_overrides:get_dbだけを差し替え、SQLiteのインメモリDBはStaticPoolで1接続に固定する。

以下、各項目をコードと実行結果つきで説明します。FastAPIの入門や起動方法はFastAPIとは|最短で動かす手順とasync/defの使い分けで扱っているため、この記事はデータベースを含む構成設計に絞ります。

2025〜2026年の変更で古くなった書き方

2024年以前の記事には、今の版では警告が出る書き方や、挙動が変わった機能を使うコードが含まれています。2025年9月以降の変更のうち、FastAPIとSQLAlchemyの構成に影響するものを並べました。

変更 版(日付) 構成への影響
yield後の処理をレスポンス送信後へ戻す FastAPI 0.118.0(2025-09-29) yield後のcommit失敗が応答に出ない
Depends(scope="function")を追加 FastAPI 0.121.0(2025-11-03) 後始末を応答前に終えられる
pydantic.v1のサポートを削除 FastAPI 0.128.0(2025-12-27) orm_modeなどv1の書き方は不可
pyproject.tomlのentrypointを文書化 FastAPI 0.135.2(2026-03-01) CLIにパスを渡さずに済む
テストクライアントがhttpx2に対応 Starlette 1.2.0(2026-05-28) 1.8.0はhttpxのままだと警告
Python 3.11以上が必須 SQLAlchemy 2.1.0(2026-09-24) 3.10の環境は2.0系に固定
greenletを既定で入れない SQLAlchemy 2.1.0 非同期はsqlalchemy[asyncio]が必要
postgresql://の既定がpsycopg 3 SQLAlchemy 2.1.0 psycopg2だけの環境は接続不可

FastAPI側の変更はFastAPIのリリースノート、SQLAlchemy側はSQLAlchemy 2.1の移行ガイド、httpx2はStarletteのリリースノートで確認できます。最後の行は手元で再現しており、psycopg2-binaryだけを入れたvenvでcreate_engine("postgresql://...")を呼ぶとModuleNotFoundError: No module named 'psycopg'で止まりました。新規の開発はSQLAlchemy 2.1とPython 3.11以上で始めます。Python 3.10を外せない既存環境は2.0系(2026年10月15日時点の最新は9月15日公開の2.0.54)を使い、2.1へ上げる前にドライバとgreenletの有無を確認します。

ディレクトリ構成のベストプラクティス

FastAPIのディレクトリ構成に公式の決まりはありません。公式チュートリアルのBigger Applications(複数ファイルのアプリ)はAPIRouterでファイルを分ける方法までで、DB層の置き場所は扱っていません。実務で使われる型は、ファイルの種類で分ける構成と、ドメイン(機能)単位で分ける構成の2つです。

ファイル種別で分ける構成(小〜中規模の標準)

この記事のコードはすべて次の構成で動かしています。

.
├── pyproject.toml
├── alembic.ini
├── alembic/
│   ├── env.py
│   └── versions/
├── app/
│   ├── main.py            # FastAPIの生成・ルーター登録・lifespan
│   ├── core/
│   │   └── config.py      # pydantic-settingsの設定クラス
│   ├── db/
│   │   ├── base.py        # DeclarativeBaseと制約名の規則
│   │   └── session.py     # エンジン・セッション・get_db
│   ├── models/            # SQLAlchemyのテーブル定義
│   │   ├── __init__.py    # 全モデルをここでimport
│   │   ├── user.py
│   │   └── post.py
│   ├── schemas/
│   │   └── user.py        # Pydanticの入出力スキーマ
│   ├── repositories/
│   │   └── user.py        # DBの読み書き
│   ├── services/
│   │   └── user.py        # 業務ルールとcommit
│   └── api/
│       ├── deps.py        # SessionDepなどの共通依存
│       └── routers/
│           └── users.py
└── tests/
    ├── conftest.py
    └── test_users.py

依存の向きは api → services → repositories → models の一方向にそろえ、modelsからapiをimportしないようにします。向きを決めておくと、ファイルが増えたときの循環importを防げます。models/__init__.pyで全モデルをimportしておくのは、Alembicの自動生成とテストのテーブル作成が、モデルの読み込み漏れで差分を見落とさないようにするためです。

ドメイン単位で分ける構成(ドメインが多いモノリス)

src/
├── auth/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── dependencies.py
│   ├── service.py
│   └── exceptions.py
├── posts/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── ...
├── config.py      # 全体の設定
├── database.py    # DB接続
└── main.py

GitHubのfastapi-best-practices(zhanymkanov)は、ファイル種別の構成について「マイクロサービスや小さなプロジェクトではうまくいくが、多くのドメインを持つモノリスでは伸びなかった」と書き、NetflixのDispatchに倣ったドメイン単位の構成を勧めています。別ドメインの部品を使うときはfrom src.auth import constants as auth_constantsのように、モジュール名を明示してimportします。

判断軸 ファイル種別 ドメイン単位
ドメインの数 数個まで 多い
1機能の追加で触るディレクトリ 複数(models・schemas・api等) 原則1つ
共通処理の置き場 各層の中 src直下のモジュール
向く体制 1チーム・マイクロサービス ドメインごとに担当が分かれる

最初はファイル種別で始めます。ドメイン単位は、1つのディレクトリに別機能のファイルが混ざって探しにくくなった時点、あるいは担当チームがドメインで分かれた時点で移す構成で、エンドポイントが十数本の段階で採用すると、ディレクトリの数だけが増えます。

pyproject.tomlのentrypointによるアプリ位置の固定

[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.11"

[tool.fastapi]
entrypoint = "app.main:app"

FastAPI CLIのドキュメントは、[tool.fastapi]のentrypointにアプリの場所を書く方法を案内しています。fastapi dev app/main.pyのようにパスを毎回渡す必要がなくなり、VS Code拡張やFastAPI Cloudもアプリを見つけられます。上の構成で引数なしのfastapi runを実行すると、起動ログは次のとおりでした(行頭の絵文字は省いています)。

$ fastapi run
Starting FastAPI in production mode
Using import string: app.main:app
Server started at http://0.0.0.0:8000

fastapiコマンドはfastapi-cliが提供するため、pip install "fastapi[standard]"で入れます。この記事のアプリとテストを再現するには、pip install "fastapi[standard]" sqlalchemy pydantic-settings alembic "psycopg[binary]" pytest httpx2で必要なパッケージがそろいます。

設定値の一元管理とpydantic-settings

from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    database_url: str = "sqlite:///./app.db"
    sql_echo: bool = False

@lru_cache
def get_settings() -> Settings:
    return Settings()

BaseSettingsを継承したクラスは、引数で渡されなかった項目を環境変数(と.env)から読みます。FastAPIの設定のドキュメントのとおりget_settings()に@lru_cacheを付けると、Settingsは初回呼び出しで1回だけ作られ、リクエストのたびに.envを読み直しません。DATABASE_URLを環境変数で渡せば、開発はSQLite、本番はPostgreSQLとコードを変えずに切り替えられ、後述のAlembicも同じget_settings()から接続先を読みます。pydantic-settings 2.16.0もPython 3.11以上が必要です。

SQLAlchemyモデル定義のベストプラクティス

DeclarativeBaseとMapped[]による2.0以降の書き方

from datetime import datetime

from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db.base import Base

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    name: Mapped[str | None] = mapped_column(String(100))
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())

    posts: Mapped[list["Post"]] = relationship(back_populates="author")
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db.base import Base

class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(200))
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))

    author: Mapped["User"] = relationship(back_populates="posts")

2.0以降はMapped[]の型注釈から列の型とNULL可否が決まり、Mapped[str | None]ならNULL許可、Mapped[str]ならNOT NULLです。この書き方は2.1でも変わりません。relationshipはback_populatesで双方向を明示します。SQLAlchemyとPydanticを1クラスで兼ねるSQLModelもあり、FastAPI公式のSQLデータベースのチュートリアルはSQLModelで書かれています。SQLModelでもテーブル用と入出力用のモデルは分けられます。この記事はSQLAlchemyのモデルとPydanticのスキーマを別ファイルに分け、テーブルの列とレスポンスの項目を別々に変更できる形にしています。

制約名の命名規則(naming_convention)の初期設定

from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase

# 制約名の規則を決めておくと、Alembicの差分で名前が安定する
NAMING_CONVENTION = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}

class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=NAMING_CONVENTION)

Alembicのマイグレーションは制約を名前で参照します(Alembicの命名規則のドキュメント)。名前を付けないとDB側の自動命名になり、後で制約を消す・変えるときに実際の名前を調べる手間が生じます。このBaseを使ってalembic revision --autogenerateを実行すると、生成されたスクリプトの制約名は規則どおりになりました。

sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
sa.ForeignKeyConstraint(['author_id'], ['users.id'], name=op.f('fk_posts_author_id_users')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_posts'))

規則が使われるのは名前を付けていない制約とインデックスで、名前を付けた制約はその名前のまま作られます(my_uqと名付けたUNIQUE制約はmy_uqのままでした)。例外がこの例のckで、%(constraint_name)sを使うため、CHECK制約にはname=が必須です。CheckConstraint("x > 0", name="positive")をtestテーブルに付けるとck_test_positiveになり、名前を付けないとInvalidRequestError(Naming convention including %(constraint_name)s token requires that constraint is explicitly named.)で止まりました。運用中のDBに後から規則を入れても既存の制約名は変わらないため、そろえたい場合は改名のマイグレーションを別に書きます。最初のマイグレーションより前に入れておくのが一番手間がかかりません。

データベース接続とセッション管理

エンジンとセッションの生成(SQLAlchemy 2.1の既定ドライバ)

from collections.abc import Iterator

from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

from app.core.config import get_settings

settings = get_settings()
engine = create_engine(settings.database_url, echo=settings.sql_echo, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)

def get_db() -> Iterator[Session]:
    with SessionLocal() as session:
        yield session

SQLAlchemy 2.1では、ドライバ名を省いたpostgresql://の既定DBAPIがpsycopg2からpsycopg(psycopg 3)に変わりました。検証環境でcreate_engineのdialect.driverを確かめるとpsycopgでした。PostgreSQLならpip install "psycopg[binary]"を入れ、URLをpostgresql+psycopg://と明示しておくと、2.0と2.1のどちらでも同じドライバで動きます。両ドライバの違いはpsycopg2とpsycopg3の違いにまとめています。MySQLはmysql+pymysql://、SQL Serverはmssql+pyodbc://のようにURLとドライバを替えます。

pool_pre_ping=Trueはプールから接続を取り出すときに生存確認をして、DB側で切れた接続を再利用するエラーを防ぎます。処理やトランザクションの途中で切れた接続までは救えません(SQLAlchemyの接続プールのドキュメント)。autoflush=Falseは、2.1でautoflushがORM文かCore文かを問わず全実行で走るようになったこともあり、flushの時機をリポジトリ側で決めるために切っています。expire_on_commit=Falseはcommit後に属性を読み直しに行かない設定で、commitの後にレスポンスへ変換するFastAPIの流れでは余分なSELECTを防ぎます。

get_dbの依存性注入とcommitの置き場所

from typing import Annotated

from fastapi import Depends
from sqlalchemy.orm import Session

from app.db.session import get_db

SessionDep = Annotated[Session, Depends(get_db)]

エンドポイントではsession: SessionDepと書くだけでセッションを受け取れます。FastAPIは同じリクエスト内で同じ依存を1回だけ呼んで結果を使い回すので(サブ依存のドキュメント)、1リクエストの中で複数のリポジトリが同じセッションを共有します。

迷いやすいのはcommitの置き場所です。yieldの後にsession.commit()を書く例をよく見かけます。

# 避ける書き方:yieldの後でcommitする
def get_db_commit_on_exit():
    with SessionLocal() as session:
        yield session
        session.commit()  # FastAPI 0.118.0以降はレスポンス送信後に実行される

FastAPI 0.118.0以降、yieldの後の処理はレスポンスを送ってから実行されます。commit時に検査される一意制約(DEFERRABLE INITIALLY DEFERRED)を付けたテーブルへ同じ値を2回送ると、2回目はcommitがIntegrityErrorで失敗したのに、クライアントには200が返りました。DBに残った行は1件です。

$ curl -sS -w ' %{http_code}
' -X POST localhost:8000/items/1   # 1回目
{"saved":1} 200
$ curl -sS -w ' %{http_code}
' -X POST localhost:8000/items/1   # 2回目(commitで一意制約違反)
{"saved":1} 200
# サーバーログ
sqlalchemy.exc.IntegrityError: (psycopg.errors.UniqueViolation) duplicate key value violates unique constraint "t_uq"

標準の対策は、commitをサービス層(小規模ならエンドポイント)で明示的に呼ぶことです。commitの例外がエンドポイントの中で起きるので、409や500として正しく返せます。後始末をどうしても依存関数の側で済ませたい場合は、FastAPI 0.121.0で入ったscopeを使います。

# yieldの後のcommitをレスポンス送信前に終わらせたい場合
SessionCommitDep = Annotated[Session, Depends(get_db_commit_on_exit, scope="function")]

scope="function"にすると、yieldの後の処理がレスポンス送信前に実行されます(Advanced Dependenciesのドキュメント)。上と同じcommit付きの依存関数をこの指定で使うと、同じ値の2回目は500(Internal Server Error)で返り、失敗がクライアントに届きました。scopeだけ変えても、commitを書いていないget_dbのままでは何も保存されない点に注意します。後始末に1秒かかる依存で測ると、既定(scope="request")のレスポンスは0.02〜0.09秒、scope="function"は1.17秒で、後始末を待ってから返っていました。ストリーミングのレスポンスでセッションを使い続けるなら既定のままにします。

リポジトリパターンとサービス層の分け方

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import User

class UserRepository:
    def __init__(self, session: Session) -> None:
        self.session = session

    def get(self, user_id: int) -> User | None:
        return self.session.get(User, user_id)

    def get_by_email(self, email: str) -> User | None:
        return self.session.scalar(select(User).where(User.email == email))

    def add(self, user: User) -> User:
        self.session.add(user)
        self.session.flush()
        return user
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.models import User
from app.repositories.user import UserRepository
from app.schemas.user import UserCreate

class EmailAlreadyExists(Exception):
    pass

def register_user(session: Session, data: UserCreate) -> User:
    repo = UserRepository(session)
    if repo.get_by_email(data.email):
        raise EmailAlreadyExists(data.email)
    try:
        user = repo.add(User(email=data.email, name=data.name))
        session.commit()  # トランザクションの境界はサービス層で決める
    except IntegrityError as exc:
        # 事前確認とINSERTの間に同じメールが登録された(同時登録)場合
        session.rollback()
        if repo.get_by_email(data.email) is not None:
            raise EmailAlreadyExists(data.email) from exc
        raise
    return user

リポジトリは1テーブルの読み書きに徹し、flush()までにとどめます。commitの判断をサービス層が持つので、複数のリポジトリにまたがる処理を1トランザクションにまとめられます。register_userはメールの重複を業務エラー(EmailAlreadyExists)として送出し、HTTPの409への変換はルーターに任せています。事前の重複確認だけでは、確認とINSERTの間に同じメールが登録される同時登録を防げないため、IntegrityErrorをrollbackしてから同じ業務エラーに変換しています。この処理を外した版では、後述の3本目のテストがsqlite3.IntegrityError: UNIQUE constraint failed: users.emailで落ちました。サービス層がHTTPExceptionを知らない形にしておくと、同じ処理をバッチやCLIからも呼べます。

小規模では採用しない判断もありえます。エンドポイントが数本で、同じクエリが2か所以上に出てこないうちは、リポジトリを作らずエンドポイントからセッションを直接使って構いません。同じselectが複数のルーターに現れた時点、あるいはテストでDBアクセスを差し替えたくなった時点で切り出します。FastAPIを使ったAPIの設計・開発を外部に依頼したい場合は、issohのAPI開発・システム連携でも支援しています。

Pydanticスキーマとレスポンスの分離

from pydantic import BaseModel, ConfigDict, Field

class UserCreate(BaseModel):
    email: str = Field(max_length=255)
    name: str | None = Field(default=None, max_length=100)

class UserRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    email: str
    name: str | None
from fastapi import APIRouter, HTTPException, status

from app.api.deps import SessionDep
from app.repositories.user import UserRepository
from app.schemas.user import UserCreate, UserRead
from app.services.user import EmailAlreadyExists, register_user

router = APIRouter(prefix="/users", tags=["users"])

@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def create_user(data: UserCreate, session: SessionDep):
    try:
        return register_user(session, data)
    except EmailAlreadyExists:
        raise HTTPException(status.HTTP_409_CONFLICT, "email already registered")

@router.get("/{user_id}", response_model=UserRead)
def read_user(user_id: int, session: SessionDep):
    user = UserRepository(session).get(user_id)
    if user is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
    return user

response_modelや戻り値の型で出力項目を絞らずにSQLAlchemyのオブジェクトを返すと、パスワードハッシュのような外に出したくない属性まで応答に含まれるおそれがあります。入力用(UserCreate)と出力用(UserRead)を分け、出力側にmodel_config = ConfigDict(from_attributes=True)を付けます。v1のorm_mode = Trueがこの名前に変わったもので、FastAPI 0.128.0ではpydantic.v1のサポート自体が外れました。response_model=UserReadを指定すると、UserReadに定義した列だけがJSONになり、同じ型がSwagger UI(/docs)のスキーマにも出ます。ConfigDictのほかの項目はPydantic v2のConfigDict設定一覧で整理しています。

同期と非同期の選び方(async defの落とし穴)

async defの中で同期セッションを呼んだときの待ち時間

@app.get("/async")
async def in_async(session: SessionDep):
    session.execute(text("select pg_sleep(0.5)"))  # 同期セッションをasync defで呼ぶ
    return {"ok": True}

@app.get("/sync")
def in_sync(session: SessionDep):
    session.execute(text("select pg_sleep(0.5)"))
    return {"ok": True}

同じselect pg_sleep(0.5)を、httpxのAsyncClientから10本同時に送った結果です。エンジンはpool_size=20で作り、uvicorn block:app(ワーカー1つ)で起動しました。計測用のクライアントは次のとおりで、各経路に1回アクセスしてからasyncio.gatherで10本を同時に送り、全応答までの経過時間をtime.perf_counterで測っています。秒数はこの条件(Intel Mac・ローカルのPostgreSQL 18.6)での測定例です。

import asyncio, sys, time, httpx
async def main(path, n=10):
    async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=60) as c:
        await c.get(path)  # ウォームアップ
        t = time.perf_counter()
        await asyncio.gather(*[c.get(path) for _ in range(n)])
        print(path, n, "requests:", round(time.perf_counter() - t, 2), "s")
asyncio.run(main(sys.argv[1]))
/async 10 requests: 5.13 s
/sync 10 requests: 0.58 s
/async 10 requests: 5.06 s
/sync 10 requests: 0.56 s

async def版は5.13秒、def版は0.58秒でした。async defの関数はイベントループの上で直接動くため、同期ドライバの待ち時間がループを止め、リクエストが1本ずつ処理されます。defの関数はFastAPIがスレッドプールで動かすので並行に進みます。同期のSQLAlchemyを使うなら、エンドポイントもyieldの依存関数もdefで書きます。

AsyncSessionに切り替える条件と必要な設定

# pip install "sqlalchemy[asyncio]" "psycopg[binary]"
from collections.abc import AsyncIterator

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload

from app.models import User

engine = create_async_engine("postgresql+psycopg://user:pass@localhost/app")
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db() -> AsyncIterator[AsyncSession]:
    async with AsyncSessionLocal() as session:
        yield session

async def get_user(session: AsyncSession, user_id: int) -> User | None:
    # 関連は取得時に読み込む(遅延ロードはMissingGreenletになる)
    return await session.scalar(
        select(User).where(User.id == user_id).options(selectinload(User.posts))
    )

非同期で揃えるなら、エンジンからエンドポイントまで全部を非同期にします。SQLAlchemy 2.1ではgreenletが既定で入らなくなり、入れずにcreate_async_engineを使うとImportError: The SQLAlchemy asyncio module requires that the Python 'greenlet' library is installed.で止まりました。もう1つの落とし穴は遅延ロードで、AsyncSessionで取得したユーザーのuser.postsをそのまま読むとMissingGreenletになったため、selectinload(User.posts)で先に読み込みます。expire_on_commit=Falseは、SQLAlchemyのasyncio拡張のドキュメントが非同期では通常Falseにすべきとしている設定です。

非同期への切り替えは、スレッドの空き待ちが応答時間を延ばしていると測定で確かめられたときに検討します。defのエンドポイントを動かすAnyIOのスレッド数の上限は、検証環境(AnyIO 4.15.1)で40でした。Starletteのスレッドプールの説明のとおり、この40は同期の依存関数などとも共有され、total_tokensで変更もできます。DB接続プールの待ち時間と合わせて測り、スレッド待ちが目立たず外部APIの呼び出しも少ないなら、同期のSQLAlchemyとdefのほうがコードが単純で、遅延ロードの例外にも悩まされません。

Alembicによるマイグレーション運用

pip install alembic
alembic init alembic
# ここで次のコード例のとおりalembic/env.pyを編集してから、以下を実行する
alembic revision --autogenerate -m "create users and posts"
alembic upgrade head
alembic check          # モデルとDBに差分が残っていないかを確認
# alembic/env.py(変更する箇所だけ)
from app.core.config import get_settings
from app.db.base import Base
from app import models  # noqa: F401  全モデルを登録する

config = context.config
config.set_main_option(
    "sqlalchemy.url", get_settings().database_url.replace("%", "%%")
)
target_metadata = Base.metadata

本番のテーブルはBase.metadata.create_all()で作らず、Alembicの履歴で管理します。env.pyでtarget_metadataにBase.metadataを渡し、接続先を設定クラスから読むと、アプリとマイグレーションで接続先がずれません。

自動生成で気をつけたいのは、metadataに無いテーブルを削除対象として出す点です。検証中、DBに残っていた別のテーブルtに対して次のログが出て、生成スクリプトにdrop_tableが入りました。

INFO  [alembic.autogenerate.compare.tables] Detected added table 'posts'
INFO  [alembic.autogenerate.compare.tables] Detected removed table 't'

別のツールが作るテーブルが同じDBにあるなら、env.pyのinclude_objectで除外するか、生成スクリプトを適用前に必ず読みます。適用後はalembic checkでモデルとDBに差分が残っていないかを確かめられ、今回はNo new upgrade operations detected.と出ました。Alembicの使い方と自動生成の落とし穴はAlembic(アレンビック)とはで詳しく扱っています。

テストのベストプラクティス:dependency_overridesとSQLite

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from app.db.base import Base
from app.db.session import get_db
from app.main import app
from app import models  # noqa: F401  テーブル定義を登録する

@pytest.fixture
def client():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(engine)
    TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)

    def override_get_db():
        with TestingSession() as session:
            yield session

    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()
    engine.dispose()
def test_create_and_read_user(client):
    res = client.post("/users", json={"email": "[email protected]", "name": "A"})
    assert res.status_code == 201
    user_id = res.json()["id"]

    res = client.get(f"/users/{user_id}")
    assert res.status_code == 200
    assert res.json() == {"id": user_id, "email": "[email protected]", "name": "A"}

def test_duplicate_email_returns_409(client):
    client.post("/users", json={"email": "[email protected]"})
    res = client.post("/users", json={"email": "[email protected]"})
    assert res.status_code == 409

def test_concurrent_duplicate_returns_409(client, monkeypatch):
    from app.repositories.user import UserRepository

    client.post("/users", json={"email": "[email protected]"})
    original = UserRepository.get_by_email
    calls = {"n": 0}

    def miss_first_check(self, email):
        # 事前確認をすり抜けた重複INSERTを模擬する(実際の並行実行ではない)
        calls["n"] += 1
        return None if calls["n"] == 1 else original(self, email)

    monkeypatch.setattr(UserRepository, "get_by_email", miss_first_check)
    res = client.post("/users", json={"email": "[email protected]"})
    assert res.status_code == 409
$ pytest -q
...                                                                      [100%]
3 passed in 0.20s
  • 差し替えるのはget_dbだけ:app.dependency_overrides[get_db]でセッションだけをテスト用に替え、ルーティングとスキーマの検証は本物のまま通します。
  • インメモリDBはStaticPoolで1接続に固定:sqlite://は接続ごとに別のDBになるため、poolclass=StaticPoolで同じ接続を使い回します。check_same_thread=Falseは、TestClientがエンドポイントを別スレッドで動かすので必要です。
  • 差し替えは後片付けする:テストの最後にapp.dependency_overrides.clear()を呼び、他のテストに差し替えを残しません。

検証中に2つの罠を踏みました。1つ目は、conftest.pyでimport app.modelsと書いた版で、名前appがFastAPIのインスタンスからパッケージappに上書きされ、AttributeError: module 'app' has no attribute 'dependency_overrides'で全テストが落ちました。from app import modelsと書けば起きません。2つ目はStarlette 1.8.0のTestClientで、httpxのままだとUsing `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.という警告が出て、pip install httpx2で消えました。SQLiteで再現できない差(JSON型・部分インデックス・遅延評価の制約など)を確かめるテストは、本番と同じ版のPostgreSQLに専用のテストDBを用意し、別のfixtureでその接続URLからエンジンを作ってget_dbを差し替えます。掲載のfixtureはSQLite固定なので、DATABASE_URLを変えてもテスト先は変わりません。PostgreSQL用のfixtureではStaticPoolとcheck_same_threadを外し、テストごとにテーブルの作成と削除を行います。

DBアクセスで避けるべきアンチパターン

ここまでの章で扱った「async defの中の同期セッション」「yieldの後のcommit」のほかに、本番で問題になりやすいものを挙げます。

N+1問題(20件の取得で21クエリ)

users = session.scalars(select(User)).all()
total = sum(len(u.posts) for u in users)          # 1人ごとにSELECTが飛ぶ

users = session.scalars(
    select(User).options(selectinload(User.posts))  # 投稿をまとめて取得
).all()
lazy users= 20 posts= 60 queries= 21
selectinload users= 20 posts= 60 queries= 2

ユーザー20人・各3件の投稿で数えると、遅延ロードのままループでuser.postsを読むと21クエリ、selectinloadを付けると2クエリでした。一覧APIで関連を返すときは、取得時にselectinloadかjoinedloadを指定します。

セッションのグローバル共有と起動時のcreate_all

モジュールの先頭でSessionを1つ作って使い回すと、並行するリクエストの変更が同じトランザクションに混ざります。セッションは必ずget_dbでリクエストごとに作ります。本番の起動処理(lifespan)でcreate_all()を呼ぶ構成も避けます。create_all()は存在しないテーブルを作るだけで、既存テーブルへの列の追加や型の変更は反映しないため、Alembicの履歴とDBの実体がずれていきます。lifespanでは、この記事のmain.pyのように終了時のengine.dispose()だけを書いておけば足ります。

from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.api.routers import users
from app.db.session import engine

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    engine.dispose()  # 終了時に接続プールを閉じる

app = FastAPI(lifespan=lifespan)
app.include_router(users.router)

よくある質問

FastAPIのディレクトリ構成に決まりはありますか?

公式に決まった構成はありません。公式チュートリアルはAPIRouterでファイルを分ける方法までを示しています。DBを使うアプリなら、models・schemas・repositories・services・apiに分けるファイル種別の構成で始め、ドメインが増えて1つのディレクトリに別機能のファイルが混ざり始めたらドメイン単位の構成へ移すのが扱いやすい順序です。

SQLAlchemyは2.0と2.1のどちらで書くべきですか?

Python 3.11以上が使えるなら2.1で始めます。モデルの書き方(DeclarativeBase・Mapped[]・mapped_column())は2.0と同じです。2.0から上げるときは、既定ドライバ(psycopg 3)・greenlet・autoflushの3つの変更を確認し、Python 3.10の環境は2.0系に留めます。

Pydanticのorm_modeが使えないのはなぜですか?

Pydantic v2でfrom_attributesに名前が変わったためです。model_config = ConfigDict(from_attributes=True)と書くと、SQLAlchemyのオブジェクトから属性を読んでスキーマを作れます。FastAPIは0.128.0でpydantic.v1のサポートを外したため、v1の書き方を残したコードは最新版のFastAPIでは動きません。

リポジトリパターンは小規模でも必要ですか?

必須ではありません。エンドポイントが数本で、同じクエリが2か所以上に出てこないうちは、エンドポイントからセッションを直接使っても回ります。切り出す目安は本文のとおりですが、commitをサービス層(またはエンドポイント)で呼ぶ形だけは、規模にかかわらず最初からそろえておきます。

PostgreSQL・MySQL・SQL Serverで同じコードが動きますか?

SQLAlchemyのORMはDBの違いを吸収するので、接続URLとドライバを替えれば大部分は共通です。PostgreSQLはpostgresql+psycopg://、MySQLはmysql+pymysql://、SQL Serverはmssql+pyodbc://が代表的な指定で、SQLAlchemy 2.1ではSQL Server向けにmssql-pythonドライバへの対応も入りました。JSON型や全文検索などDB固有の機能を使う箇所は差が出るため、テストは本番と同じDBでも回します。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.29 テックブログ ソニー生命の不正アクセス:38.5万件の対象確認と窓口、停止中の手続き
  2. 2026.10.31 テックブログ 松屋の不正アクセス:237万件の対象確認と窓口、漏れたパスワードの守り方
  3. 2026.10.31 テックブログ 無印良品の情報漏洩:MUJIアプリ最大2,726万件の対象確認となりすましメール対策
  4. 2026.10.31 テックブログ osinaの情報漏洩:約23万人の対象確認とSNS連携の解除、分析ツールへ渡すデータの絞り方
  5. 2026.05.18 テックブログ マネーフォワードで連携できない・失敗するときの対処法|エラー別の原因と再連携のやり方

RELATED POSTS 関連記事

目次