【TypeScript × Drizzle ORM】完全ガイド|スキーマ定義・CRUD・JOIN・マイグレーション・Prismaとの比較まで徹底解説

【TypeScript × Drizzle ORM】完全ガイド|スキーマ定義・CRUD・JOIN・マイグレーション・Prismaとの比較まで徹底解説 TypeScript

TypeScriptでデータベースを扱う際、Drizzle ORMは近年急速に採用が広がっているライブラリです。Prismaが「型安全なORM」として長く主流でしたが、Drizzleは「SQLに近い書き心地」と「完全な型安全性」を両立する点で差別化に成功しています。

本記事では、Drizzle ORMの基礎から実務で使える応用パターンまでを、具体的なコード例とともに解説します。

この記事でわかること

  • Drizzle ORMの設計思想とPrismaとの違い
  • スキーマ定義と型の自動生成(InferSelectModel / InferInsertModel)
  • SELECT・INSERT・UPDATE・DELETE の型安全な書き方
  • WHERE条件・ORDER BY・LIMITの組み立て方
  • JOINとリレーション定義
  • トランザクション処理
  • drizzle-kit によるマイグレーション管理
  • Repository パターンを使った実務設計

Prismaとの型定義の比較についてはTypeScript × Prisma 完全ガイドもあわせてご覧ください。

スポンサーリンク

Drizzle ORMとは

Drizzle ORMは2023年以降に急成長しているTypeScript向けのORM/クエリビルダーです。「ORM」という名前ですが、その設計思想はSQLクエリをTypeScriptの型システムで表現することにあります。

最大の特徴はランタイムゼロ・スキーマファーストの型推論です。スキーマをTypeScriptコードで定義すると、テーブルの型・クエリ結果の型・INSERT用の型がすべて自動で生成されます。

比較項目 Drizzle ORM Prisma
スキーマ定義 TypeScriptコード(schema.ts) 専用DSL(schema.prisma)
型生成 スキーマから自動推論(codegen不要) prisma generateが必要
SQLの透明性 高い(SQLに近い構文) 低い(抽象化が強い)
バンドルサイズ 軽量(~50KB) 重い(クライアント別ビルド)
Edge Runtime対応 対応(libsqlなど) 制限あり
マイグレーション drizzle-kit(SQL出力) prisma migrate
Raw SQL sql“ テンプレートで型付き $queryRawで型付き
学習コスト SQLの知識が活かせる Prisma DSLを学ぶ必要あり
Drizzleが向いているケース
複雑なSQLを書く機会が多い場合、Edge Runtimeで動かしたい場合、バンドルサイズを抑えたい場合に特に有効です。一方、直感的なリレーション操作や充実したエコシステムを優先するならPrismaも依然として強力な選択肢です。
インストール(PostgreSQL使用時)
# Drizzle ORM本体とPostgreSQLドライバ
npm install drizzle-orm postgres
npm install --save-dev drizzle-kit @types/pg

# SQLite使用時
# npm install drizzle-orm @libsql/client

# MySQL使用時
# npm install drizzle-orm mysql2

スキーマ定義と型の自動生成

Drizzleのスキーマ定義はTypeScriptファイルに記述します。pgTable()を使ってテーブルを定義すると、InferSelectModel(SELECT結果の型)とInferInsertModel(INSERT用の型)が自動生成されます。

src/db/schema.ts
import {
  pgTable,
  serial,
  varchar,
  integer,
  boolean,
  timestamp,
  text,
} from "drizzle-orm/pg-core";
import { InferSelectModel, InferInsertModel, relations } from "drizzle-orm";

// usersテーブル
export const users = pgTable("users", {
  id:        serial("id").primaryKey(),
  name:      varchar("name", { length: 100 }).notNull(),
  email:     varchar("email", { length: 255 }).notNull().unique(),
  age:       integer("age"),
  isActive:  boolean("is_active").notNull().default(true),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

// postsテーブル
export const posts = pgTable("posts", {
  id:        serial("id").primaryKey(),
  title:     varchar("title", { length: 255 }).notNull(),
  content:   text("content"),
  published: boolean("published").notNull().default(false),
  authorId:  integer("author_id").references(() => users.id, { onDelete: "cascade" }),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

// --- 型の自動生成 ---
// SELECT結果の型(全カラムが含まれる)
export type User    = InferSelectModel<typeof users>;
export type Post    = InferSelectModel<typeof posts>;

// INSERT用の型(自動生成フィールドはOptionalになる)
export type NewUser = InferInsertModel<typeof users>;
export type NewPost = InferInsertModel<typeof posts>;

// --- リレーション定義 ---
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

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

生成された型を確認すると、スキーマから完全に型が推論されているのがわかります。

型の確認例
// User型(InferSelectModel)
// {
//   id: number;
//   name: string;
//   email: string;
//   age: number | null;   ← notNullなしはnullableになる
//   isActive: boolean;
//   createdAt: Date;
//   updatedAt: Date;
// }

// NewUser型(InferInsertModel)
// {
//   name: string;
//   email: string;
//   age?: number | null;       ← Optionalに
//   isActive?: boolean;        ← default値があるのでOptional
//   createdAt?: Date;          ← defaultNow()があるのでOptional
//   id?: number;               ← serial(自動採番)なのでOptional
// }

データベース接続の設定

src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

const connectionString = process.env.DATABASE_URL!;

// 接続プールの作成
const client = postgres(connectionString, {
  max: 10,            // 最大接続数
  idle_timeout: 20,   // アイドルタイムアウト(秒)
});

// drizzleインスタンス(schemaを渡すとリレーションクエリが使えるようになる)
export const db = drizzle(client, { schema });

// 型エクスポート(テスト等で使いやすくする)
export type Database = typeof db;

SELECT:データを取得する

Drizzleのクエリ構文はSQLに非常に近いため、SQLを知っている開発者はすぐに馴染めます。

基本的なSELECT
import { db } from "./db";
import { users } from "./db/schema";
import { eq, and, gte, like, isNull, desc, asc } from "drizzle-orm";

// --- 全件取得 ---
const allUsers = await db.select().from(users);
// 型: User[]

// --- 条件指定(WHERE)---
const activeUsers = await db
  .select()
  .from(users)
  .where(eq(users.isActive, true));

// --- AND条件 ---
const filtered = await db
  .select()
  .from(users)
  .where(
    and(
      eq(users.isActive, true),
      gte(users.age, 18),
    )
  );

// --- LIKE検索 ---
const searched = await db
  .select()
  .from(users)
  .where(like(users.name, "%田%"));

// --- カラムを絞って取得 ---
const names = await db
  .select({ id: users.id, name: users.name })
  .from(users);
// 型: { id: number; name: string }[]

// --- ORDER BY + LIMIT + OFFSET ---
const paginated = await db
  .select()
  .from(users)
  .orderBy(desc(users.createdAt))
  .limit(10)
  .offset(0);

// --- NULL チェック ---
const noAge = await db
  .select()
  .from(users)
  .where(isNull(users.age));

INSERT:データを追加する

INSERT
import { db } from "./db";
import { users } from "./db/schema";
import type { NewUser } from "./db/schema";

// --- 単件INSERT ---
const newUser: NewUser = {
  name: "山田太郎",
  email: "yamada@example.com",
};

const [inserted] = await db
  .insert(users)
  .values(newUser)
  .returning(); // INSERTした行を返す
// 型: User

// --- 複数件INSERT ---
const newUsers: NewUser[] = [
  { name: "鈴木花子", email: "suzuki@example.com" },
  { name: "田中次郎", email: "tanaka@example.com", age: 30 },
];

const insertedUsers = await db
  .insert(users)
  .values(newUsers)
  .returning({ id: users.id, name: users.name });
// 型: { id: number; name: string }[]

// --- UPSERT(ON CONFLICT) ---
import { onConflictDoUpdate } from "drizzle-orm";

await db
  .insert(users)
  .values({ name: "山田太郎", email: "yamada@example.com" })
  .onConflictDoUpdate({
    target: users.email, // 衝突判定カラム
    set: { name: "山田太郎(更新済み)", updatedAt: new Date() },
  });

UPDATE・DELETE

UPDATE / DELETE
import { db } from "./db";
import { users } from "./db/schema";
import { eq, lt } from "drizzle-orm";

// --- UPDATE ---
const 2026/03/22 = await db
  .update(users)
  .set({
    name: "山田太郎(更新)",
    updatedAt: new Date(),
  })
  .where(eq(users.id, 1))
  .returning();

// --- DELETE ---
await db
  .delete(users)
  .where(eq(users.id, 1));

// --- 古いレコードを一括削除 ---
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30日前
await db
  .delete(users)
  .where(lt(users.createdAt, cutoff));

JOIN:テーブルを結合する

DrizzleのJOINはSQLそのままの構文で記述でき、結果の型が正確に推論されます。

JOINクエリ
import { db } from "./db";
import { users, posts } from "./db/schema";
import { eq } from "drizzle-orm";

// --- INNER JOIN ---
const usersWithPosts = await db
  .select({
    userId:    users.id,
    userName:  users.name,
    postId:    posts.id,
    postTitle: posts.title,
  })
  .from(users)
  .innerJoin(posts, eq(posts.authorId, users.id));
// 型: { userId: number; userName: string; postId: number; postTitle: string }[]

// --- LEFT JOIN(投稿がいないユーザーも含む)---
const allUsersWithPosts = await db
  .select({
    user: users,
    post: posts,  // 投稿がない場合はnullになる
  })
  .from(users)
  .leftJoin(posts, eq(posts.authorId, users.id));
// 型: { user: User; post: Post | null }[]

// --- 集計クエリ(投稿数をカウント)---
import { count, sql } from "drizzle-orm";

const userPostCounts = await db
  .select({
    userId:    users.id,
    userName:  users.name,
    postCount: count(posts.id),
  })
  .from(users)
  .leftJoin(posts, eq(posts.authorId, users.id))
  .groupBy(users.id, users.name)
  .orderBy(desc(count(posts.id)));

リレーションクエリ(withを使った方法)

DrizzleにはリレーションクエリAPIもあります。スキーマでリレーションを定義しておくと、with句でネストしたオブジェクトを取得できます。

リレーションクエリ
import { db } from "./db";
import { users } from "./db/schema";
import { eq } from "drizzle-orm";

// --- ユーザーと投稿を一括取得 ---
const usersWithPosts = await db.query.users.findMany({
  with: {
    posts: {
      where: eq(posts.published, true),
      orderBy: [desc(posts.createdAt)],
      limit: 5,
    },
  },
});
// 型: (User & { posts: Post[] })[]

// --- 単一ユーザーの取得 ---
const user = await db.query.users.findFirst({
  where: eq(users.id, 1),
  with: { posts: true },
  columns: {
    id: true,
    name: true,
    email: true,
    // isActive: false にすると除外できる
  },
});
// 型: { id: number; name: string; email: string; posts: Post[] } | undefined

トランザクション

トランザクション
import { db } from "./db";
import { users, posts } from "./db/schema";

async function createUserWithPost(userData: NewUser, postTitle: string) {
  return await db.transaction(async (tx) => {
    // txはdbと同じAPIを持つ
    const [user] = await tx
      .insert(users)
      .values(userData)
      .returning();

    const [post] = await tx
      .insert(posts)
      .values({
        title: postTitle,
        authorId: user.id,
      })
      .returning();

    // エラーが発生すると自動でロールバックされる
    return { user, post };
  });
}

// ネストしたトランザクション(savepoint)
await db.transaction(async (tx) => {
  await tx.insert(users).values({ name: "A", email: "a@example.com" });

  try {
    await tx.transaction(async (innerTx) => {
      await innerTx.insert(users).values({ name: "B", email: "b@example.com" });
      throw new Error("内部ロールバック"); // 内側だけロールバック
    });
  } catch (_) {
    console.log("内部トランザクションがロールバックされました");
  }
  // 外側のトランザクション(ユーザーA)はコミットされる
});

Raw SQL:型付きの生SQLを実行する

Drizzleは複雑なクエリにはsql“テンプレートタグを使った型付きRaw SQLを提供しています。

型付きRaw SQL
import { db } from "./db";
import { sql } from "drizzle-orm";
import type { User } from "./db/schema";

// --- 型を指定したRawクエリ ---
const result = await db.execute<User>(
  sql`SELECT * FROM users WHERE age > ${18} ORDER BY created_at DESC`
);
// プレースホルダーは自動でバインドされるのでSQLインジェクション安全

// --- 集計や関数をカラムに使う ---
const stats = await db
  .select({
    total:   sql<number>`count(*)::int`,
    avgAge:  sql<number | null>`avg(${users.age})::numeric(5,1)`,
    maxAge:  sql<number | null>`max(${users.age})`,
  })
  .from(users)
  .where(eq(users.isActive, true));
// 型: { total: number; avgAge: number | null; maxAge: number | null }[]

drizzle-kit によるマイグレーション管理

Drizzleのマイグレーションはdrizzle-kitというCLIツールで管理します。スキーマファイルを変更するとSQLマイグレーションファイルが自動生成される仕組みです。

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

export default defineConfig({
  schema:    "./src/db/schema.ts",  // スキーマファイルのパス
  out:       "./drizzle",            // マイグレーションファイルの出力先
  dialect:   "postgresql",           // "mysql" | "sqlite" も可
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose:   true,   // 差分の詳細を表示
  strict:    true,   // 破壊的変更の前に確認
});
マイグレーションコマンド
# スキーマの差分からSQLマイグレーションを生成
npx drizzle-kit generate

# マイグレーションを適用(DB更新)
npx drizzle-kit migrate

# GUIでDB・スキーマを確認(Drizzle Studio)
npx drizzle-kit studio

# 現在のスキーマとDBの状態を比較
npx drizzle-kit check

# DBから既存スキーマを逆生成(既存DBからDrizzleを始める場合)
npx drizzle-kit introspect

マイグレーション生成後は drizzle/ ディレクトリにSQLファイルが作成されます。このファイルはGitにコミットしてチームで共有します。

マイグレーションのポイント
strict: trueを設定しておくと、カラム削除やテーブル名変更などの破壊的変更を実行前に確認できます。本番環境では必ずdrizzle-kit generateで生成したSQLを事前にレビューしてからmigrateを実行してください。

WHERE条件ヘルパー関数一覧

Drizzleには豊富なWHERE条件ヘルパーが用意されています。主なものを確認しておきましょう。

関数 SQL相当 使用例
eq(col, val) col = val eq(users.id, 1)
ne(col, val) col != val ne(users.isActive, false)
gt / gte(col, val) col > / >= val gte(users.age, 18)
lt / lte(col, val) col < / <= val lt(posts.createdAt, date)
like(col, pattern) col LIKE pattern like(users.name, “%田%”)
ilike(col, pattern) col ILIKE pattern(大文字小文字無視) ilike(users.email, “%@gmail%”)
inArray(col, vals) col IN (vals) inArray(users.id, [1,2,3])
notInArray(col, vals) col NOT IN (vals) notInArray(users.id, blocked)
isNull(col) col IS NULL isNull(users.age)
isNotNull(col) col IS NOT NULL isNotNull(users.age)
between(col, min, max) col BETWEEN min AND max between(users.age, 20, 30)
and(...conditions) cond1 AND cond2 … and(eq(…), gte(…))
or(...conditions) cond1 OR cond2 … or(eq(…), eq(…))
not(condition) NOT condition not(eq(users.isActive, false))

Repositoryパターンを使った実務設計

実務ではRepositoryパターンでDBアクセスをカプセル化すると、テストの書きやすさと保守性が向上します。

src/repositories/user.repository.ts
import { db } from "../db";
import { users } from "../db/schema";
import { eq, like, and, desc } from "drizzle-orm";
import type { User, NewUser } from "../db/schema";

export class UserRepository {
  constructor(private readonly db: Database) {}

  async findById(id: number): Promise&lt;User | undefined> {
    return this.db.query.users.findFirst({
      where: eq(users.id, id),
    });
  }

  async findByEmail(email: string): Promise&lt;User | undefined> {
    return this.db.query.users.findFirst({
      where: eq(users.email, email),
    });
  }

  async findMany(opts: {
    search?: string;
    isActive?: boolean;
    limit?: number;
    offset?: number;
  } = {}): Promise&lt;User[]> {
    const { search, isActive, limit = 20, offset = 0 } = opts;

    const conditions = [];
    if (search)           conditions.push(like(users.name, `%${search}%`));
    if (isActive !== undefined) conditions.push(eq(users.isActive, isActive));

    return this.db
      .select()
      .from(users)
      .where(conditions.length > 0 ? and(...conditions) : undefined)
      .orderBy(desc(users.createdAt))
      .limit(limit)
      .offset(offset);
  }

  async create(data: NewUser): Promise&lt;User> {
    const [user] = await this.db
      .insert(users)
      .values(data)
      .returning();
    return user;
  }

  async update(id: number, data: Partial&lt;NewUser>): Promise&lt;User | undefined> {
    const [user] = await this.db
      .update(users)
      .set({ ...data, updatedAt: new Date() })
      .where(eq(users.id, id))
      .returning();
    return user;
  }

  async delete(id: number): Promise&lt;boolean> {
    const result = await this.db
      .delete(users)
      .where(eq(users.id, id))
      .returning({ id: users.id });
    return result.length > 0;
  }
}

// 利用例
const userRepo = new UserRepository(db);
const user = await userRepo.findByEmail("yamada@example.com");

テスト

DrizzleはJestやVitestと組み合わせてテストを書けます。テスト用にインメモリのSQLite(@libsql/client)を使うと、実際のDBを用意せずにロジックのテストができます。

テスト例(Vitest + SQLite)
import { describe, it, expect, beforeEach } from "vitest";
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import { migrate } from "drizzle-orm/libsql/migrator";
import * as schema from "../db/schema";
import { UserRepository } from "../repositories/user.repository";

// テスト用インメモリDB
const client = createClient({ url: ":memory:" });
const testDb = drizzle(client, { schema });

beforeEach(async () => {
  // マイグレーションを適用(テスト前にテーブルを作成)
  await migrate(testDb, { migrationsFolder: "./drizzle" });
});

describe("UserRepository", () => {
  it("ユーザーを作成して取得できる", async () => {
    const repo = new UserRepository(testDb);

    const created = await repo.create({
      name: "テストユーザー",
      email: "test@example.com",
    });

    expect(created.id).toBeDefined();
    expect(created.name).toBe("テストユーザー");

    const found = await repo.findById(created.id);
    expect(found).toEqual(created);
  });

  it("存在しないIDはundefinedを返す", async () => {
    const repo = new UserRepository(testDb);
    const found = await repo.findById(9999);
    expect(found).toBeUndefined();
  });
});

まとめ

Drizzle ORMはSQLの知識を活かしながら型安全なDBアクセスを実現できる、非常に実用的なライブラリです。Prismaと異なりcodegen不要でスキーマから型が自動生成される点、SQLに近い構文で直感的に書ける点が大きな強みです。

項目 内容
スキーマ定義 pgTable/mysqlTable/sqliteTableでTypeScriptコードとして記述
型自動生成 InferSelectModel・InferInsertModelで取得・INSERT用型を取得
WHERE条件 eq/gte/like/inArray/and/orなどのヘルパー関数を組み合わせる
JOIN innerJoin/leftJoin構文・リレーションクエリAPIの2通り
トランザクション db.transaction(async tx => {})でセーフポイントも対応
マイグレーション drizzle-kitで差分SQLを生成・適用。Drizzle Studioでデータを確認
実務設計 Repositoryパターンでカプセル化するとテストがしやすい

エラーハンドリングの型安全なパターンについてはTypeScript エラーハンドリング完全ガイドも参考にしてください。またNext.jsと組み合わせる場合はTypeScript × Next.js App Router 完全ガイドもあわせてご覧ください。