Tips & Tricks (更新: 2026/6/7)

Drizzle ORM 使い方入門:SQLに近い型安全なスキーマとマイグレーション

Drizzle ORMをClaude Codeで実装。SQLに近い書き味でスキーマ定義、drizzle-kitのマイグレーション、リレーショナルクエリ、Zod連携まで。Prismaとの違いも。

Drizzle ORM 使い方入門:SQLに近い型安全なスキーマとマイグレーション

「ORMを入れて」とClaude Codeに頼んだら、見たことのない独自構文がどっさり生成されて、SQLが得意な同僚が「これ、結局どんなクエリが飛ぶの?」と固まった。

僕がDrizzle ORMに乗り換えたのは、まさにこの瞬間でした。重い抽象化の裏で何が起きているか分からないORMは、便利な日は最高に便利で、トラブルの日は最悪に手も足も出ない。Drizzleはその逆で、書いたコードがほぼそのままSQLになります。db.select().from(posts).where(...) と書けば、頭の中で SELECT * FROM posts WHERE ... が浮かぶ。この「翻訳のいらなさ」が効きます。

この記事は、Drizzleの使い方を「スキーマ定義 → マイグレーション → クエリ → 検証」の順に、コピペで動くコードで追います。題材は小さなブログAPI。Claude Codeへの頼み方と、人間が必ず読むべき差分もセットで書きます。Prismaと迷っている人向けの違いも最後にまとめます。

この記事の要点

  • Drizzleは「TypeScriptで書くSQL」。型推論は効くのに、生成されるSQLが透けて見えるほど薄い。
  • スキーマは pgTable で定義し、drizzle-kit generate でSQLマイグレーションを生成 → 人間がSQLを読んでから migrate
  • リレーショナルクエリ(db.query)でN+1なしにネストしたデータを1発で取れる。
  • Zod連携(drizzle-orm/zod)でAPI入力を実行時に検証。型だけでは外から来た値は守れない。
  • Prismaとの違いは「薄さ」。抽象が強いのがPrisma、SQLの見通しが強いのがDrizzle。スキーマ設計そのものの判断軸は別記事へ。

Drizzle ORMって、要するに何?

ORM(Object-Relational Mapping)は、SQLを毎回文字列で組み立てる代わりに、アプリ側の型とDB操作をつなぐ道具です。「ORM」という言葉が硬いだけで、やることは「テーブルをコードで表して、型のついた操作にする」だけ。

Drizzleの立ち位置は、たとえるなら「カーナビではなく、見やすい紙の地図」です。カーナビ(重いORM)は目的地を言えば連れて行ってくれるけれど、なぜその道なのかは分からない。地図(Drizzle)は自分で道を選ぶぶん、渋滞も抜け道も自分の目で見える。SQLを読める人にとっては、この透明さが安心につながります。

具体的には、こんな性質を持っています。

  • SQLに近い書き味selectfromwhereleftJoin がそのままメソッドになっている。
  • 型推論が強い:スキーマを1か所書けば、クエリの結果型まで自動で決まる。post.title を打つと補完が出るし、存在しない列は赤線になる。
  • ランタイムが薄い:裏で巨大なクエリエンジンが動かないので、サーバーレスやエッジでも軽い。
  • マイグレーションはSQLとして残るdrizzle-kit が生成するのは生のSQLファイル。何が変わるか、コミットを開けば全部見える。

Claude Codeへの最初の頼み方

ここが一番事故るポイントなので先に書きます。「Drizzleを入れて」だけだと、Claude Codeはスキーマだけ作ってマイグレーションを忘れたり、onDelete: "cascade" を効かせすぎたり、seedが2回目で壊れるコードを平気で出します。DB層は画面と違って、壊れ方が静かです。ビルドは通るのに、本番のマイグレーションで初めて止まる。

なので、依頼は具体化します。曖昧さを削るだけで出力品質はかなり変わります。

Drizzle ORMでブログAPIのDB層を実装してください。

前提:
- PostgreSQL
- drizzle-orm + drizzle-kit + node-postgres(pg)
- User, Post, Category, Comment, AuditLog を扱う
- Post は draft / published / archived の状態を持つ
- slug と email は一意にする
- 記事一覧は status, publishedAt, author, category, search で絞り込む
- Post 削除時は Comment と中間テーブルだけ cascade する
- User 削除で Post まで消さない(restrict)

出力:
1. package.json の scripts
2. drizzle.config.ts
3. db/schema.ts(index, unique, relation, onDelete を明示)
4. db/client.ts
5. db/posts.ts の create / list / publish
6. db/seed.ts(冪等に)
7. Zod 連携
8. GitHub Actions での CI 検証
9. マイグレーション SQL のレビュー観点

ポイントは、削除ルール(onDelete)を文章で指定していることです。ここを言わないと、Claude Codeは「とりあえず cascade」にしがちで、User削除でPostまで巻き込む設計を出してきます。事業ルールは人間にしか分かりません。

セットアップ:3つのパッケージだけ

最小構成はこれだけです。pg がドライバ、drizzle-orm が本体、drizzle-kit がマイグレーション生成ツール。

npm i drizzle-orm pg zod
npm i -D drizzle-kit @types/pg tsx typescript vitest

package.json の scripts を整えます。日々叩くのはこの数本です。

{
  "type": "module",
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:check": "drizzle-kit check",
    "db:studio": "drizzle-kit studio",
    "db:seed": "tsx db/seed.ts",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  }
}

設定ファイルは drizzle.config.ts の1枚。スキーマの場所、出力先、方言(PostgreSQL)、接続情報を書きます。

// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

strict: true は、drizzle-kit が危ない変更を黙って実行しないようにする保険です。本番では drizzle-kit push(スキーマを直接DBへ反映)に頼らず、generate でSQLを残し、PRで読んでから migrate する運用にします。ここを横着すると、ある日「いつ消えたんだこの列」になります。DATABASE_URL はローカルPostgreSQL、Supabase、Neon、Railwayなどの接続文字列に差し替えてください。

型安全なスキーマを定義する

スキーマ(DBの表の設計図)はDrizzleの中心です。pgTable でテーブルを宣言し、列の型・notNulldefault・index・references(外部キー)を並べます。Claude Codeに書かせたら、index・unique・relation・onDelete は必ず人間が読みます。

// db/schema.ts
import { relations } from "drizzle-orm";
import {
  boolean,
  index,
  integer,
  jsonb,
  pgEnum,
  pgTable,
  primaryKey,
  text,
  timestamp,
  uniqueIndex,
  uuid,
  varchar,
} from "drizzle-orm/pg-core";

// 状態は enum で固定する(typo を型で防ぐ)
export const postStatus = pgEnum("post_status", [
  "draft",
  "published",
  "archived",
]);

export const users = pgTable(
  "users",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    email: varchar("email", { length: 255 }).notNull(),
    name: varchar("name", { length: 120 }).notNull(),
    role: varchar("role", { length: 40 }).default("editor").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    uniqueIndex("users_email_unique").on(table.email),
    index("users_role_idx").on(table.role),
  ],
);

export const categories = pgTable(
  "categories",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    slug: varchar("slug", { length: 120 }).notNull(),
    name: varchar("name", { length: 120 }).notNull(),
  },
  (table) => [uniqueIndex("categories_slug_unique").on(table.slug)],
);

export const posts = pgTable(
  "posts",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    slug: varchar("slug", { length: 160 }).notNull(),
    title: varchar("title", { length: 160 }).notNull(),
    body: text("body").notNull(),
    status: postStatus("status").default("draft").notNull(),
    // User を消しても Post は残す = restrict
    authorId: uuid("author_id")
      .notNull()
      .references(() => users.id, { onDelete: "restrict" }),
    viewCount: integer("view_count").default(0).notNull(),
    featured: boolean("featured").default(false).notNull(),
    publishedAt: timestamp("published_at", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    uniqueIndex("posts_slug_unique").on(table.slug),
    index("posts_status_published_at_idx").on(table.status, table.publishedAt),
    index("posts_author_id_idx").on(table.authorId),
  ],
);

export const postCategories = pgTable(
  "post_categories",
  {
    // 中間テーブルは Post を消したら一緒に消えてよい = cascade
    postId: uuid("post_id")
      .notNull()
      .references(() => posts.id, { onDelete: "cascade" }),
    categoryId: uuid("category_id")
      .notNull()
      .references(() => categories.id, { onDelete: "cascade" }),
  },
  (table) => [primaryKey({ columns: [table.postId, table.categoryId] })],
);

export const comments = pgTable(
  "comments",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    postId: uuid("post_id")
      .notNull()
      .references(() => posts.id, { onDelete: "cascade" }),
    authorId: uuid("author_id")
      .notNull()
      .references(() => users.id, { onDelete: "restrict" }),
    body: text("body").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    index("comments_post_created_at_idx").on(table.postId, table.createdAt),
    index("comments_author_id_idx").on(table.authorId),
  ],
);

export const auditLogs = pgTable(
  "audit_logs",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    action: varchar("action", { length: 80 }).notNull(),
    targetId: uuid("target_id").notNull(),
    metadata: jsonb("metadata").$type<Record<string, unknown>>(),
    createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [index("audit_logs_action_created_at_idx").on(table.action, table.createdAt)],
);

// リレーション定義(リレーショナルクエリで使う)
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
  comments: many(comments),
}));

export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
  comments: many(comments),
  categories: many(postCategories),
}));

export const categoriesRelations = relations(categories, ({ many }) => ({
  posts: many(postCategories),
}));

export const postCategoriesRelations = relations(postCategories, ({ one }) => ({
  post: one(posts, { fields: [postCategories.postId], references: [posts.id] }),
  category: one(categories, {
    fields: [postCategories.categoryId],
    references: [categories.id],
  }),
}));

export const commentsRelations = relations(comments, ({ one }) => ({
  post: one(posts, { fields: [comments.postId], references: [posts.id] }),
  author: one(users, { fields: [comments.authorId], references: [users.id] }),
}));

// スキーマから型を直接取り出せる(手書きの型定義が要らない)
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

最後の $inferSelect / $inferInsert がDrizzleの気持ちよさです。スキーマ1か所から「取得時の型」と「挿入時の型」が自動で生える。別ファイルに型を手書きして二重管理、という地獄から解放されます。

短く用語を置いておきます。スキーマは「表の設計図」、マイグレーションは「設計図の変更をDBへ反映するSQL履歴」、seedは「検証用の初期データ」、トランザクションは「複数のDB操作を全部成功か全部失敗にそろえる処理」。Claude Codeはこの説明なしでも理解しますが、PRやレビューで人間同士が同じ言葉を使えると事故が減ります。

マイグレーションを生成して、SQLを自分の目で読む

ここがDrizzleの「薄さ」が一番効くところです。drizzle-kit generate を叩くと、スキーマの差分から生のSQLファイルが drizzle/0000_xxx.sql のように出ます。これを読んでから流す。

npm run db:generate   # スキーマ差分から SQL を生成
npm run db:check      # マイグレーション同士の整合をチェック
npm run db:migrate    # 実際に DB へ適用

生成されるSQLは、だいたいこんな形です。

CREATE TYPE "public"."post_status" AS ENUM('draft', 'published', 'archived');

CREATE TABLE "users" (
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
  "email" varchar(255) NOT NULL,
  "name" varchar(120) NOT NULL,
  "role" varchar(40) DEFAULT 'editor' NOT NULL,
  "created_at" timestamp with time zone DEFAULT now() NOT NULL,
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
);

CREATE TABLE "posts" (
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
  "slug" varchar(160) NOT NULL,
  "title" varchar(160) NOT NULL,
  "body" text NOT NULL,
  "status" "post_status" DEFAULT 'draft' NOT NULL,
  "author_id" uuid NOT NULL,
  "view_count" integer DEFAULT 0 NOT NULL,
  "featured" boolean DEFAULT false NOT NULL,
  "published_at" timestamp with time zone,
  "created_at" timestamp with time zone DEFAULT now() NOT NULL,
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
);

ALTER TABLE "posts"
  ADD CONSTRAINT "posts_author_id_users_id_fk"
  FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE restrict;

CREATE UNIQUE INDEX "users_email_unique" ON "users" ("email");
CREATE UNIQUE INDEX "posts_slug_unique" ON "posts" ("slug");
CREATE INDEX "posts_status_published_at_idx" ON "posts" ("status", "published_at");

PRで見るべき点は3つに絞れます。

  1. DROP TABLE / DROP COLUMN が意図せず入っていないか。 スキーマから1行消すと、Drizzleは素直に列を消すSQLを出します。データごと消えます。
  2. 既存データがある列へ NOT NULL を足して本番で止まらないか。 空の行があると、その追加は失敗します。
  3. 外部キーの ON DELETE が事業ルールと合っているか。 ブログならPost削除でCommentは消えてよい一方、User削除でPostまで消えると監査や売上分析が崩れます。

ここはTypeScriptのビルドでは絶対に気づけない領域です。だから「SQLを読む」工程を運用に組み込みます。マイグレーション全般の考え方はClaude Codeでデータベースマイグレーションに、テーブル設計そのものの判断軸(正規化・index)はテーブル設計の判断軸にまとめてあります。

クエリとトランザクション

接続ファイルは薄く保ちます。サーバーレスでは接続プールの扱いが変わるので、本番環境に合わせてDrizzle ORM公式ドキュメントで確認してください。

// db/client.ts
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

// schema を渡すと db.query.* のリレーショナルクエリが使える
export const db = drizzle(pool, { schema });

作成・一覧・公開をまとめた実装です。createPostpublishPost はトランザクションで「記事の更新」と「監査ログ」を1セットにしています。

// db/posts.ts
import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "./client";
import { auditLogs, categories, comments, postCategories, posts, users } from "./schema";
import { createPostInputSchema } from "./validation";

type CreatePostInput = z.infer<typeof createPostInputSchema>;

export async function createPost(input: CreatePostInput) {
  const data = createPostInputSchema.parse(input); // 入口で実行時検証

  return db.transaction(async (tx) => {
    const [post] = await tx
      .insert(posts)
      .values({
        slug: data.slug,
        title: data.title,
        body: data.body,
        authorId: data.authorId,
      })
      .returning();

    for (const slug of data.categorySlugs) {
      const [category] = await tx
        .insert(categories)
        .values({ slug, name: slug })
        .onConflictDoUpdate({ target: categories.slug, set: { name: slug } })
        .returning();

      await tx
        .insert(postCategories)
        .values({ postId: post.id, categoryId: category.id })
        .onConflictDoNothing();
    }

    await tx.insert(auditLogs).values({
      action: "post.create",
      targetId: post.id,
      metadata: { slug: post.slug },
    });

    return post;
  });
}

export async function listPublishedPosts(
  params: { page?: number; perPage?: number; search?: string } = {},
) {
  const page = Math.max(params.page ?? 1, 1);
  const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50); // 上限を必ず固定
  const where = params.search
    ? and(eq(posts.status, "published"), ilike(posts.title, `%${params.search}%`))
    : eq(posts.status, "published");

  const [items, [{ total }]] = await Promise.all([
    db
      .select({
        id: posts.id,
        slug: posts.slug,
        title: posts.title,
        publishedAt: posts.publishedAt,
        authorName: users.name,
        commentCount: sql<number>`count(${comments.id})::int`,
      })
      .from(posts)
      .innerJoin(users, eq(posts.authorId, users.id))
      .leftJoin(comments, eq(comments.postId, posts.id))
      .where(where)
      .groupBy(posts.id, posts.slug, posts.title, posts.publishedAt, users.name)
      .orderBy(desc(posts.publishedAt), desc(posts.createdAt))
      .limit(perPage)
      .offset((page - 1) * perPage),
    db.select({ total: sql<number>`count(*)::int` }).from(posts).where(where),
  ]);

  return {
    items,
    pagination: { page, perPage, total, totalPages: Math.ceil(total / perPage) },
  };
}

export async function publishPost(postId: string) {
  return db.transaction(async (tx) => {
    const [current] = await tx
      .select({ id: posts.id, slug: posts.slug, status: posts.status })
      .from(posts)
      .where(eq(posts.id, postId))
      .limit(1);

    if (!current) throw new Error("Post not found");
    if (current.status === "published") return current; // 二重公開を防ぐ

    const [published] = await tx
      .update(posts)
      .set({ status: "published", publishedAt: new Date(), updatedAt: new Date() })
      .where(eq(posts.id, postId))
      .returning({ id: posts.id, slug: posts.slug, status: posts.status });

    await tx.insert(auditLogs).values({
      action: "post.publish",
      targetId: published.id,
      metadata: { slug: published.slug },
    });

    return published;
  });
}

見てのとおり、selectinnerJoingroupBy も、SQLの構文がそのままメソッドになっています。複雑な一覧クエリほど、この透明さが効きます。ひとつだけ鉄則:トランザクションの中でメール送信や外部API呼び出しをしないこと。DB接続を長く握るとデッドロックやタイムアウトの温床になります。通知が必要なら、トランザクション内ではoutboxテーブルへ書き、送信は別ワーカーに任せます。

N+1を避けるリレーショナルクエリ

ネストしたデータが欲しいときは、db.query を使うと1回のクエリでまとめて取れます。記事ごとに著者やコメントを別々に取りに行く「N+1問題」を避けられます。

// 記事+著者+コメント(投稿者名つき)を1発で取る
const postWithRelations = await db.query.posts.findFirst({
  where: (p, { eq }) => eq(p.slug, "hello-drizzle"),
  with: {
    author: { columns: { name: true } },
    comments: {
      orderBy: (c, { desc }) => desc(c.createdAt),
      with: { author: { columns: { name: true } } },
    },
  },
});

with で関連を指定するだけ。これが効くのは、最初に db.query が使えるよう client.tsschema を渡しているからです。

seedは冪等に書く

冪等(idempotent)とは、同じ処理を何度実行しても壊れないこと。seedは開発中に何度も叩くので、insert 一本だと2回目でunique制約に当たって落ちます。onConflictDoUpdate(PostgreSQLの ON CONFLICT ... DO UPDATE)で「あれば更新、なければ挿入」にします。

// db/seed.ts
import { db, pool } from "./client";
import { categories, postCategories, posts, users } from "./schema";

async function main() {
  const [user] = await db
    .insert(users)
    .values({ email: "[email protected]", name: "Masa", role: "admin" })
    .onConflictDoUpdate({
      target: users.email,
      set: { name: "Masa", role: "admin", updatedAt: new Date() },
    })
    .returning();

  const [category] = await db
    .insert(categories)
    .values({ slug: "drizzle", name: "Drizzle ORM" })
    .onConflictDoUpdate({ target: categories.slug, set: { name: "Drizzle ORM" } })
    .returning();

  const [post] = await db
    .insert(posts)
    .values({
      slug: "claude-code-drizzle-demo",
      title: "Claude Code Drizzle demo",
      body: "A seeded post for local verification.",
      status: "published",
      authorId: user.id,
      publishedAt: new Date(),
    })
    .onConflictDoUpdate({
      target: posts.slug,
      set: { status: "published", updatedAt: new Date() },
    })
    .returning();

  await db
    .insert(postCategories)
    .values({ postId: post.id, categoryId: category.id })
    .onConflictDoNothing();

  console.log({ user: user.email, post: post.slug, category: category.slug });
}

main()
  .catch((error) => {
    console.error(error);
    process.exitCode = 1;
  })
  .finally(async () => {
    await pool.end();
  });

Zodで「外から来た値」を止める

TypeScriptの型は、実行時の入力を1ミリも検査しません。コンパイルが終われば型は消えるので、APIやフォームから来たJSONはただの unknown です。だからDBへ入れる前にZodで検証します。

Drizzleのスキーマから直接Zodスキーマを作れます。現在は drizzle-orm/zod が一級サポート(drizzle-zod パッケージはこちらに統合され、レガシー扱い)。既存プロジェクトで drizzle-zod を使っているなら、入れ替える前に依存を確認してください。

// db/validation.ts
import { createInsertSchema } from "drizzle-orm/zod";
import { z } from "zod";
import { posts } from "./schema";

export const createPostInputSchema = createInsertSchema(posts, {
  slug: (schema) =>
    schema.min(3).max(160).regex(/^[a-z0-9-]+$/, "小文字・数字・ハイフンのみ"),
  title: (schema) => schema.min(1).max(160),
  body: (schema) => schema.min(50),
})
  .pick({ slug: true, title: true, body: true, authorId: true })
  .extend({
    // フォーム都合の入力(DBの中間テーブルとは別物)
    categorySlugs: z.array(z.string().min(1).max(120)).min(1).max(5),
  });

ここで勘違いしやすいのが、ZodとDB制約の役割分担です。DBの uniqueforeign key最後の防波堤。Zodは入口で分かりやすいエラーを返す検査。両方あって初めて固くなります。入力検証をもっと広く設計したいならClaude CodeでZodバリデーションへ。

CIでマイグレーション忘れと型崩れを止める

ローカルで動いても、マイグレーションの生成忘れや型の崩れはPRで止めたい。CIでPostgreSQLを立て、db:generatedb:checkmigratetypechecktest を流します。

# .github/workflows/drizzle.yml
name: drizzle
on:
  pull_request:
jobs:
  db:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgresql://app:app@localhost:5432/app_test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run db:generate
      - run: npm run db:check
      - run: npm run db:migrate
      - run: npm run db:seed
      - run: npm run typecheck
      - run: npm test

ひとつ注意。db:migrate が緑でも、それは「空のテスト用DBでSQLが流せた」だけです。本番の既存データで NOT NULL 追加が安全とは限りません。CIは最低限の防御で、SQLの人間レビューを置き換えるものではない、と割り切ります。

DrizzleとPrismaの違い:薄さで選ぶ

「結局どっちを使えばいいの」とよく聞かれます。一言でいうと、抽象の強さで選ぶです。

観点Drizzle ORMPrisma
書き味SQLに近い(select/where/joinがそのまま)独自のクエリAPI(findManyなど)
生成SQLの見通し透明。書いたコードがほぼSQL抽象が厚く、裏のSQLは見えにくい
マイグレーション生のSQLファイルをコミット独自のmigrationエンジン
ランタイムの重さ薄い。サーバーレス/エッジ向きやや重い(エンジンを伴う)
学習のとっつきSQLを知っていると速いSQLを知らなくても入りやすい
向いている人SQLを読める・クエリを自分で制御したいDBをあまり意識せず素早く作りたい

迷ったときの僕の基準はシンプルです。チームにSQLが読める人がいて、一覧や管理画面の重いクエリを自分で握りたいならDrizzle。DBをなるべく意識せず手早く立ち上げたいならPrisma。 どちらが優れているという話ではなく、透明さと手軽さのトレードオフです。

Prismaの型安全クエリやN+1回避を具体的に見たいならPrisma入門:include/selectとN+1回避を、ORM選びの前にテーブル設計を固めたいならテーブル設計の判断軸を先に読むと、判断がぶれません。

よくある質問

Q. drizzle-kit pushgenerate + migrate、どっちを使う? A. 開発の使い捨てDBや試作は push(スキーマを直接反映)で速くてOK。本番はほぼ確実に generate → SQLレビュー → migrate。「いつ何が変わったか」がSQL履歴として残るのが本番運用では効きます。

Q. onDeletecascaderestrict、どう決める? A. 「親を消したら子も消えていいか」で判断します。記事に紐づくコメントや中間テーブルは cascade(一緒に消す)でよくても、ユーザーに紐づく記事は restrict(消させない)が安全。売上や監査につながるデータは安易にcascadeしないこと。

Q. リレーショナルクエリ(db.query)と手書きのjoin、どっち? A. ネストした構造をそのまま取りたいなら db.query + with が読みやすく、N+1も避けられます。集計(countgroup by)や細かいSQL制御が要るなら db.select の手書きjoinが向きます。両方使い分けるのが普通です。

Q. Zodがあれば、DBのunique制約は要らない? A. 要ります。Zodは入口の検査で、同時アクセスの競合までは防げません。最後にデータの正しさを保証するのはDBのunique/foreign key。役割が違うので両方置きます。

Q. Claude CodeにDrizzleを任せるとき、一番気をつけることは? A. 「対象ファイル」「変更してよいスキーマ」「触らない既存マイグレーション」を最初に明示すること。DB変更は影響範囲が広いので、担当外ファイルまで直されると差分が読めなくなります。生成されたマイグレーションSQLは必ず人間が開く、これだけは外さないでください。

実際に試した結果

検証用の小さなブログAPIで同じ流れを回してみて、一番効いたのは「スキーマを書かせる」指示ではなく、「マイグレーションSQLを読ませる」指示でした。Claude Codeは最初、User削除時のリレーションを少し強くしすぎたのですが、onDelete の意図を表にして再レビューさせたら一発で直りました。

そしてDrizzle特有の手応えとして、生成された一覧クエリを読むのがまったく苦じゃなかった。innerJoingroupBy もSQLそのままなので、「このクエリ、何が飛ぶの?」と固まる同僚がいない。冒頭の事故の逆です。

drizzle-kit generatecheck → seedの再実行 → 一覧クエリ → 公開トランザクション、までを1つの作業単位にまとめてから、あとでZodとCIを足す。この順番だと、後付けよりレビューがずっと楽でした。Drizzleは軽い道具ですが、軽いからこそ、スキーマもSQLもテストも「人間が読める形で残す」運用が一番効きます。

DB層を壊さずにClaude Codeを使う型を、自分のリポジトリにも入れたいなら、まずはこの記事のプロンプトを自分のスキーマへ置き換え、生成されたマイグレーションSQLを読む練習から。チームで安全に導入したい場合は研修・導入相談もどうぞ。

#Drizzle ORM #TypeScript #型安全 #SQL #Claude Code
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。