メインコンテンツにスキップ

PostgreSQL を使用した Drizzle ORM: 完全ガイド

PostgreSQL を使用した Drizzle ORM の完全ガイド: スキーマ設計、移行、タイプ セーフ クエリ、関係、トランザクション、TypeScript アプリの運用パターン。

E
ECOSIRE Research and Development Team
|2026年3月19日8 分で読める1.5k 語数|

Drizzle ORM は TypeScript データベース エコシステムの中で独自の位置を占めています。ORM は実際にはクエリ ビルダーにすぎず、読み取り可能な SQL を生成するコードとしてのスキーマと、データベースを信頼できる情報源のように扱う移行システムを備えています。 Prisma と TypeORM を何年も使用した後、Drizzle は生の SQL に戻ってきたように感じますが、完全な TypeScript 推論と実行時のオーバーヘッドはゼロです。

Drizzle ORM は TypeScript データベース エコシステムの中で独自の位置を占めています。ORM は実際にはクエリ ビルダーにすぎず、読み取り可能な SQL を生成するコードとしてのスキーマと、データベースを信頼できる情報源のように扱う移行システムを備えています。 Prisma と TypeORM を何年も使用した後、Drizzle は生の SQL に戻ってきたように感じますが、完全な TypeScript 推論と実行時のオーバーヘッドはゼロです。

このガイドでは、初期セットアップから運用パターン、65 を超える Drizzle スキーマ ファイルを含むコードベースからの描画、マルチテナント クエリ、Next.js と NestJS の両方のコンテキストで確実に動作する遅延プロキシ接続パターンまで、すべてを説明します。

重要なポイント

  • DB 接続には遅延プロキシを使用します。モジュールのロード時に積極的な接続を作成しないでください。
  • システム インストールとの競合を避けるために、ローカル PostgreSQL はデフォルト以外のポート (5433) で実行する必要があります。
  • マルチテナンシーにはすべてのテーブルに organizationId が必要です — スキーマ レベルで強制します
  • Drizzle 列挙型の比較では、型を絞り込むために明示的な TypeScript キャストが必要です
  • sql.raw() は決して使用しないでください。パラメーター化された値を持つ sql テンプレート リテラルを常に使用してください。
  • リレーションは外部キーとは別のものです - 完全な型安全性のために両方を定義します
  • トランザクションは複数ステップの操作をラップします。 tx オブジェクトをサービスに渡します
  • 移行が機能するためには、スキーマ インデックス (schema/index.ts) がすべてのテーブルをエクスポートする必要があります

インストールとセットアップ

Drizzle には、コア ORM とデータベース ドライバーの 2 つのパッケージが必要です。

pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg

移行とスタジオを含むフルスタックの場合:

pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit

postgres パッケージ (pg とは異なります) は、最新の TypeScript プロジェクトにおける Drizzle の推奨ドライバーです。接続プーリング、プリペアド ステートメントをサポートし、より優れた TypeScript 型を備えています。


遅延プロキシ パターン

Next.js または NestJS モノリポジトリで Drizzle を使用する場合のアーキテクチャ上の最も重要な決定は、積極的なデータベース接続を決して作成しないことです。モジュールのロード時に積極的に接続すると、次の問題が発生します。

  • Next.js: モジュールはビルド プロセス中にインポートされますが、利用可能な DB はありません
  • NestJS: 環境変数が読み込まれる前にパッケージのインポートが行われます。
  • サーバーレス: 接続オーバーヘッドによりコールド スタートが遅延する

Lazy Proxy パターンはこれらすべてを解決します。

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

let _db: ReturnType<typeof drizzle> | null = null;

function getDb() {
  if (!_db) {
    const connectionString = process.env.DATABASE_URL;
    if (!connectionString) {
      throw new Error('DATABASE_URL environment variable is not set');
    }

    const client = postgres(connectionString, {
      max: 10, // connection pool size
      idle_timeout: 20,
      connect_timeout: 10,
    });

    _db = drizzle(client, { schema });
  }
  return _db;
}

// Export a Proxy that initializes the connection on first use
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
  get(_, prop) {
    return getDb()[prop as keyof ReturnType<typeof drizzle>];
  },
});

export * from './schema';

このパターンは、@ecosire/db をインポートしてもデータベース接続が開かれないことを意味します。接続は、最初のクエリが実行されたときにのみ作成されます。


スキーマ設計

Drizzle スキーマは、テーブル定義をエクスポートするプレーンな TypeScript ファイルです。ここでの原則は、関連するテーブルを同じファイル内に保持し、中央のインデックスを通じてすべてをエクスポートすることです。

// packages/db/src/schema/contacts.ts
import {
  pgTable,
  uuid,
  text,
  varchar,
  timestamp,
  pgEnum,
  boolean,
  index,
} from 'drizzle-orm/pg-core';

export const contactTypeEnum = pgEnum('contact_type', ['individual', 'company', 'partner']);

export const contacts = pgTable(
  'contacts',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    organizationId: uuid('organization_id').notNull(), // Multi-tenancy
    name: varchar('name', { length: 255 }).notNull(),
    email: varchar('email', { length: 255 }),
    phone: varchar('phone', { length: 50 }),
    type: contactTypeEnum('type').default('individual').notNull(),
    isActive: boolean('is_active').default(true).notNull(),
    notes: text('notes'),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  (table) => ({
    organizationIdx: index('contacts_organization_idx').on(table.organizationId),
    emailIdx: index('contacts_email_idx').on(table.email),
  })
);

export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;

$inferSelect 型と $inferInsert 型を使用すると、完全な TypeScript 推論が可能になります。Drizzle は、スキーマの制約に基づいて、挿入時にどのフィールドが必須であるかオプションであるかを認識します。

// packages/db/src/schema/index.ts
export * from './contacts';
export * from './orders';
export * from './licenses';
export * from './products';
// ... all tables

インデックス ファイルは、drizzle-kit のすべてのテーブルをエクスポートして、移行の生成中にそれらのテーブルを取得する必要があります。


関係

Drizzle は、外部キー制約 (PostgreSQL によって強制される) をリレーション定義 (Drizzle のリレーショナル クエリ API によって使用される) から分離します。両方が必要です:

// packages/db/src/schema/relations.ts
import { relations } from 'drizzle-orm';
import { contacts } from './contacts';
import { orders } from './orders';
import { orderItems } from './order-items';
import { products } from './products';

export const contactsRelations = relations(contacts, ({ many }) => ({
  orders: many(orders),
}));

export const ordersRelations = relations(orders, ({ one, many }) => ({
  contact: one(contacts, {
    fields: [orders.contactId],
    references: [contacts.id],
  }),
  items: many(orderItems),
}));

export const orderItemsRelations = relations(orderItems, ({ one }) => ({
  order: one(orders, {
    fields: [orderItems.orderId],
    references: [orders.id],
  }),
  product: one(products, {
    fields: [orderItems.productId],
    references: [products.id],
  }),
}));

リレーションを定義すると、Drizzle のリレーショナル クエリ API を使用できます。

// Fetch order with contact and items in one query
const order = await db.query.orders.findFirst({
  where: eq(orders.id, orderId),
  with: {
    contact: true,
    items: {
      with: {
        product: true,
      },
    },
  },
});

Drizzle は単一の JOIN クエリを生成します。N+1 の問題はありません。


タイプセーフなクエリ

Drizzle のクエリ API は完全にタイプセーフです。選択タイプは、含める列に基づいて絞り込まれます。

import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and, like, sql, count } from 'drizzle-orm';

// Full select — returns Contact[]
const allContacts = await db
  .select()
  .from(contacts)
  .where(eq(contacts.organizationId, orgId))
  .limit(50);

// Partial select — returns only specified columns
const contactNames = await db
  .select({
    id: contacts.id,
    name: contacts.name,
    email: contacts.email,
  })
  .from(contacts)
  .where(
    and(
      eq(contacts.organizationId, orgId),
      eq(contacts.isActive, true)
    )
  );

// Count query
const [{ total }] = await db
  .select({ total: count() })
  .from(contacts)
  .where(eq(contacts.organizationId, orgId));

// Search with ILIKE (case-insensitive)
const searchResults = await db
  .select()
  .from(contacts)
  .where(
    and(
      eq(contacts.organizationId, orgId),
      like(contacts.name, `%${searchTerm}%`)
    )
  )
  .limit(20);

動的な where 句 (UI からのフィルター) の場合は、条件付きで条件を構築します。

import { SQL } from 'drizzle-orm';

async function searchContacts(orgId: string, filters: {
  search?: string;
  type?: 'individual' | 'company';
  isActive?: boolean;
}) {
  const conditions: SQL[] = [eq(contacts.organizationId, orgId)];

  if (filters.search) {
    conditions.push(like(contacts.name, `%${filters.search}%`));
  }

  if (filters.type) {
    // Enum comparisons need explicit casting
    conditions.push(eq(contacts.type, filters.type as 'individual' | 'company' | 'partner'));
  }

  if (filters.isActive !== undefined) {
    conditions.push(eq(contacts.isActive, filters.isActive));
  }

  return db
    .select()
    .from(contacts)
    .where(and(...conditions))
    .limit(100);
}

enum 値の明示的なキャストに注意してください: filters.type as 'individual' | 'company' | 'partner'。 Drizzle の列挙型は、キャストなしでは、より狭い文字列サブタイプを自動的に受け入れません。


挿入、更新、および削除

import { db } from '@ecosire/db';
import { contacts, NewContact } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';

// Insert with RETURNING
async function createContact(data: NewContact) {
  const [created] = await db
    .insert(contacts)
    .values(data)
    .returning();

  return created; // Fully typed as Contact
}

// Upsert (insert or update on conflict)
async function upsertContact(data: NewContact) {
  const [result] = await db
    .insert(contacts)
    .values(data)
    .onConflictDoUpdate({
      target: [contacts.organizationId, contacts.email],
      set: {
        name: data.name,
        updatedAt: new Date(),
      },
    })
    .returning();

  return result;
}

// Update with RETURNING
async function updateContact(
  id: string,
  orgId: string,
  updates: Partial<NewContact>
) {
  const [updated] = await db
    .update(contacts)
    .set({ ...updates, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, id),
        eq(contacts.organizationId, orgId) // Always filter by org
      )
    )
    .returning();

  return updated;
}

// Soft delete (recommended over hard delete)
async function deleteContact(id: string, orgId: string) {
  return db
    .update(contacts)
    .set({ isActive: false, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, id),
        eq(contacts.organizationId, orgId)
      )
    );
}

INSERT および UPDATE ステートメントでは常に .returning() を使用します。これにより、作成/更新されたレコードを取得するための 2 回目の SELECT ラウンドトリップが回避されます。


トランザクション

同時に成功または失敗する必要がある複数ステップの操作にはトランザクションが必要です。

async function createOrderWithItems(
  orderData: NewOrder,
  items: NewOrderItem[]
) {
  return db.transaction(async (tx) => {
    // Create the order
    const [order] = await tx
      .insert(orders)
      .values(orderData)
      .returning();

    // Create order items
    const createdItems = await tx
      .insert(orderItems)
      .values(items.map((item) => ({ ...item, orderId: order.id })))
      .returning();

    // Update inventory (must succeed or entire transaction rolls back)
    for (const item of items) {
      await tx
        .update(products)
        .set({
          stock: sql`${products.stock} - ${item.quantity}`,
        })
        .where(eq(products.id, item.productId));
    }

    return { order, items: createdItems };
  });
}

トランザクション コンテキストをサービス メソッドに渡す場合:

// Service accepts optional transaction for composability
async function activateLicense(
  licenseId: string,
  tx?: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
) {
  const queryRunner = tx || db;

  return queryRunner
    .update(licenses)
    .set({ status: 'active', activatedAt: new Date() })
    .where(eq(licenses.id, licenseId))
    .returning();
}

drizzle-kit を使用した移行

drizzle-kit CLI は、スキーマ変更から移行を生成します。

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

export default defineConfig({
  schema: './src/schema/index.ts',
  out: './src/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

ワークフロー:

# Generate migration SQL from schema changes
npx drizzle-kit generate

# Apply migrations to database
npx drizzle-kit migrate

# Push schema directly (dev only — skips migration files)
npx drizzle-kit push

# Open Drizzle Studio (visual database browser)
npx drizzle-kit studio

運用環境では、常に migrate (push ではない) を使用してください。 push コマンドはローカル開発反復用です。移行ファイルは作成されないため、変更は追跡されません。


ページネーション パターン

効率的なページネーションには、データ クエリとカウント クエリの両方が必要です。

interface PaginationParams {
  page: number;
  pageSize: number;
}

async function getContactsPaginated(orgId: string, { page, pageSize }: PaginationParams) {
  const offset = (page - 1) * pageSize;

  const [data, [{ total }]] = await Promise.all([
    db
      .select()
      .from(contacts)
      .where(eq(contacts.organizationId, orgId))
      .limit(pageSize)
      .offset(offset)
      .orderBy(contacts.createdAt),
    db
      .select({ total: count() })
      .from(contacts)
      .where(eq(contacts.organizationId, orgId)),
  ]);

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

Promise.all を使用して両方のクエリを並行して実行すると、順次実行に比べて応答時間が半分になります。


よくある落とし穴と解決策

落とし穴 1: 動的クエリに sql.raw() を使用する

// Dangerous — SQL injection vulnerability
const results = await db.execute(
  sql.raw(`SELECT * FROM contacts WHERE name = '${userInput}'`)
);

// Safe — parameterized
const results = await db.execute(
  sql`SELECT * FROM contacts WHERE name = ${userInput}`
);

落とし穴 2: 頻繁にクエリされる列のインデックスが欠落している

フィルターに使用する列には必ずインデックスを追加してください。最も一般的な欠落インデックスは次のとおりです。

(table) => ({
  organizationIdx: index('contacts_org_idx').on(table.organizationId),
  emailIdx: index('contacts_email_idx').on(table.email),
  compositeIdx: index('contacts_org_active_idx').on(
    table.organizationId,
    table.isActive
  ),
})

落とし穴 3: 手動リレーション読み込みによる N+1 クエリ

// N+1 problem — one query per order
const orders = await db.select().from(ordersTable);
for (const order of orders) {
  order.contact = await db
    .select()
    .from(contacts)
    .where(eq(contacts.id, order.contactId));
}

// Solution — use Drizzle's relational query API
const orders = await db.query.orders.findMany({
  with: { contact: true },
});

落とし穴 4: schema/index.ts から新しいテーブルをエクスポートするのを忘れる

新しいスキーマ ファイルを作成したが、schema/index.ts から再エクスポートするのを忘れた場合、drizzle-kit は新しいテーブルを認識せず、移行は生成されません。


よくある質問

TypeScript プロジェクトにおける Drizzle と Prisma を比較するとどうですか?

Drizzle は TypeScript 型を使用した生の SQL に近く、SQL に 1:1 でマップするクエリを作成します。 Prisma は、カスタム クエリ言語と別個のスキーマ ファイルを使用して、さらに抽象化します。 Drizzle は、複雑なクエリでの TypeScript 推論が優れており、実行時のオーバーヘッドが小さく、コード生成手順が必要ありません。 Prisma には、より大規模なエコシステムと、論理的な削除や監査ログなどのより多くの組み込み機能があります。 SQL に慣れている大規模なチームには、一般に Drizzle が好まれます。

drizzle-kit プッシュを使用するべきですか、それとも CI/CD で移行するべきですか?

CI/CD では常に drizzle-kit migrate を使用してください。 push コマンドは、迅速なローカル開発用です。移行ファイルを作成せずにスキーマの変更を直接適用するため、監査証跡がなく、ロールバックが困難です。デプロイメント パイプラインでは、アプリケーションを開始する前に drizzle-kit migrate を実行して、スキーマが同期されていることを確認します。

Drizzle でデータベース シードを処理するにはどうすればよいですか?

別のシード ファイルを作成し、スキーマを直接インポートします。再実行時に失敗しない冪等シードには onConflictDoNothing() を使用します。簡単に更新できるようにシード データを定数に保存し、移行後に開発セットアップ スクリプトでシードを実行します。

Drizzle と PostgreSQL を使用して全文検索を実行するにはどうすればよいですか?

PostgreSQL の to_tsvector および to_tsquery 関数では、sql テンプレート リテラルを使用します。 Drizzle は全文検索を抽象化しませんが、直接記述することができます。パフォーマンスを向上させるために tsvector 列に GIN インデックスを追加し、自然言語クエリを安全に解析するためにユーザー指定の検索語に websearch_to_tsquery を使用します。

ダウンタイムを発生させずにスキーマの変更を処理するにはどうすればよいですか?

追加的な移行を使用します。最初に列を NULL 可能として追加し、データをバックフィルしてから、NOT NULL 制約を追加します。コードから列を削除するのと同じ移行で列を削除しないでください。順序は次のとおりです: 1) 古い列を無視するコードをデプロイし、2) 古い列を削除するために移行をデプロイします。これにより、データを損失することなく常にロールバックが可能になります。


次のステップ

適切に設計されたデータベース層は、すべての実稼働アプリケーションが構築される基盤です。 ECOSIRE のエンジニアリング チームは、Drizzle ORM と PostgreSQL を毎日使用して、複雑なマルチテナント システム全体で 65 以上のスキーマ ファイルを管理しています。

データベース アーキテクチャ コンサルティング、Odoo ERP 統合、または最新の TypeScript ツールで構築された完全なバックエンド システムが必要な場合でも、当社のサービスを探索 して、当社がどのようにお手伝いできるかをご確認ください。

E

執筆者

ECOSIRE Research and Development Team

テクニカルライティング

ECOSIRE のテクニカルライティングチームは、Odoo ERP、Shopify による EC、AI エージェント、Power BI による分析、GoHighLevel による自動化、そしてエンタープライズソフトウェアのベストプラクティスを扱っています。私たちのガイドは、企業が十分な情報に基づいてテクノロジーを判断できるよう支援します。

ECOSIRE

ECOSIRE でビジネスを成長させましょう

ERP、eコマース、AI、分析、自動化にわたるエンタープライズ ソリューション。