Drizzle ORM with PostgreSQL: Complete Guide

Complete guide to Drizzle ORM with PostgreSQL: schema design, migrations, type-safe queries, relations, transactions, and production patterns for TypeScript apps.

E
ECOSIRE Research and Development Team
|19. März 202610 Min. Lesezeit2.2k Wörter|

ORM mit PostgreSQL beträufeln: Vollständiger Leitfaden

Drizzle ORM nimmt eine einzigartige Position im TypeScript-Datenbank-Ökosystem ein: Es ist das ORM, das eigentlich nur ein getarnter Abfrage-Builder ist, mit Schema-as-Code, der lesbares SQL generiert, und einem Migrationssystem, das Ihre Datenbank wie die Quelle der Wahrheit behandelt. Nach Jahren mit Prisma und TypeORM fühlt sich Drizzle an, als käme man wieder zu reinem SQL zurück – aber mit vollständiger TypeScript-Inferenz und ohne Laufzeitaufwand.

Dieser Leitfaden behandelt alles von der Ersteinrichtung bis hin zu Produktionsmustern und basiert auf einer Codebasis mit über 65 Drizzle-Schemadateien, mandantenfähigen Abfragen und einem Lazy-Proxy-Verbindungsmuster, das sowohl in Next.js- als auch in NestJS-Kontexten zuverlässig funktioniert.

Wichtige Erkenntnisse

  • Verwenden Sie einen Lazy-Proxy für die Datenbankverbindung – erstellen Sie niemals eifrige Verbindungen zur Modulladezeit – Lokales PostgreSQL sollte auf einem nicht standardmäßigen Port (5433) ausgeführt werden, um Konflikte mit Systeminstallationen zu vermeiden – Alle Tabellen benötigen organizationId für Mehrmandantenfähigkeit – auf Schemaebene erzwingen – Drizzle-Enum-Vergleiche erfordern eine explizite TypeScript-Umwandlung zur Typeingrenzung – Verwenden Sie niemals sql.raw() – verwenden Sie immer das Vorlagenliteral sql mit parametrisierten Werten
  • Beziehungen sind von Fremdschlüsseln getrennt – definieren Sie beide für vollständige Typsicherheit – Transaktionen umschließen mehrstufige Vorgänge; Übergeben Sie das tx-Objekt an Dienste – Der Schemaindex (schema/index.ts) muss alle Tabellen exportieren, damit Migrationen funktionieren

Installation und Einrichtung

Drizzle erfordert zwei Pakete: das Kern-ORM und den Datenbanktreiber.

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

Für den gesamten Stack inklusive Migrationen und Studio:

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

Das Paket postgres (anders als pg) ist der empfohlene Treiber für Drizzle in modernen TypeScript-Projekten – es unterstützt Verbindungspooling, vorbereitete Anweisungen und verfügt über bessere TypeScript-Typen.


Das Lazy-Proxy-Muster

Die wichtigste Architekturentscheidung bei Drizzle in einem Next.js- oder NestJS-Monorepo besteht darin, niemals eine eifrige Datenbankverbindung herzustellen. Eifrige Verbindungen zur Modulladezeit verursachen Probleme in:

  • Next.js: Module werden während des Build-Prozesses importiert, wenn keine DB verfügbar ist
  • NestJS: Paketimporte erfolgen, bevor Umgebungsvariablen geladen werden
  • Serverlos: Kaltstarts werden durch Verbindungs-Overhead verzögert

Das Lazy-Proxy-Muster löst all diese Probleme:

// 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';

Dieses Muster bedeutet, dass beim Importieren von @ecosire/db niemals eine Datenbankverbindung geöffnet wird. Die Verbindung wird erst erstellt, wenn die erste Abfrage ausgeführt wird.


Schema-Design

Drizzle-Schemas sind einfache TypeScript-Dateien, die Tabellendefinitionen exportieren. Die Disziplin besteht hier darin, zusammengehörige Tabellen in derselben Datei zu halten und alles über einen zentralen Index zu exportieren.

// 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;

Mit den Typen $inferSelect und $inferInsert erhalten Sie vollständige TypeScript-Inferenz – Drizzle weiß zum Einfügezeitpunkt basierend auf Ihren Schemaeinschränkungen, welche Felder erforderlich und welche optional sind.

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

Die Indexdatei muss jede Tabelle exportieren, damit drizzle-kit sie während der Migrationsgenerierung abholen kann.


Beziehungen

Drizzle trennt Fremdschlüsseleinschränkungen (durch PostgreSQL erzwungen) von Beziehungsdefinitionen (die von der relationalen Abfrage-API von Drizzle verwendet werden). Sie benötigen beides:

// 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],
  }),
}));

Wenn Beziehungen definiert sind, können Sie die relationale Abfrage-API von Drizzle verwenden:

// 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 generiert eine einzelne JOIN-Abfrage – kein N+1-Problem.


Typsichere Abfragen

Die Abfrage-API von Drizzle ist vollständig typsicher. Der Auswahltyp wird basierend darauf eingeschränkt, welche Spalten Sie einschließen:

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);

Erstellen Sie für dynamische WHERE-Klauseln (Filter aus der Benutzeroberfläche) Bedingungen bedingt:

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);
}

Beachten Sie die explizite Umwandlung auf Enumerationswerte: filters.type as 'individual' | 'company' | 'partner'. Der Enum-Typ von Drizzle akzeptiert nicht automatisch engere String-Subtypen ohne die Umwandlung.


Einfügen, Aktualisieren und Löschen

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)
      )
    );
}

Verwenden Sie immer .returning() für INSERT- und UPDATE-Anweisungen – es vermeidet einen zweiten SELECT-Roundtrip, um den erstellten/aktualisierten Datensatz abzurufen.


Transaktionen

Für mehrstufige Vorgänge, die gemeinsam erfolgreich sein oder fehlschlagen müssen, sind Transaktionen erforderlich:

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 };
  });
}

Bei der Übergabe des Transaktionskontexts an Dienstmethoden:

// 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();
}

Migrationen mit Drizzle-Kit

Die drizzle-kit-CLI generiert Migrationen aus Schemaänderungen:

// 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,
});

Arbeitsablauf:

# 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

Verwenden Sie für die Produktion immer migrate (nicht push). Der Befehl push dient der lokalen Entwicklungsiteration – er erstellt keine Migrationsdateien, sodass Änderungen nicht nachverfolgt werden.


Paginierungsmuster

Für eine effiziente Paginierung sind sowohl eine Datenabfrage als auch eine Zählabfrage erforderlich:

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),
    },
  };
}

Das parallele Ausführen beider Abfragen mit Promise.all halbiert die Antwortzeit im Vergleich zur sequenziellen Ausführung.


Häufige Fallstricke und Lösungen

Falle 1: Verwendung von sql.raw() für dynamische Abfragen

// 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}`
);

Falle 2: Fehlende Indizes für häufig abgefragte Spalten

Fügen Sie den Spalten, nach denen Sie filtern, immer Indizes hinzu. Die häufigsten fehlenden Indizes:

(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
  ),
})

Falle 3: N+1 Abfragen durch manuelles Laden von Beziehungen

// 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 },
});

Falle 4: Vergessen, neue Tabellen aus schema/index.ts zu exportieren

Wenn Sie eine neue Schemadatei erstellen, aber vergessen, sie erneut aus schema/index.ts zu exportieren, wird drizzle-kit die neue Tabelle nicht sehen und es werden keine Migrationen generiert.


Häufig gestellte Fragen

Wie schneidet Drizzle im Vergleich zu Prisma für TypeScript-Projekte ab?

Drizzle ähnelt mit TypeScript-Typen eher reinem SQL – Sie schreiben Abfragen, die 1:1 SQL zuordnen. Prisma abstrahiert mehr, mit einer benutzerdefinierten Abfragesprache und einer separaten Schemadatei. Drizzle bietet eine bessere TypeScript-Inferenz bei komplexen Abfragen, einen geringeren Laufzeitaufwand und erfordert keinen Codegenerierungsschritt. Prisma verfügt über ein größeres Ökosystem und mehr integrierte Funktionen wie Soft-Deletes und Audit-Logging. Für große Teams, die mit SQL vertraut sind, wird Drizzle im Allgemeinen bevorzugt.

Soll ich Drizzle-Kit Push verwenden oder in CI/CD migrieren?

Verwenden Sie in CI/CD immer drizzle-kit migrate. Der Befehl push dient der schnellen lokalen Entwicklung – er wendet Schemaänderungen direkt an, ohne Migrationsdateien zu erstellen, sodass kein Prüfpfad vorhanden ist und ein Rollback schwierig ist. Führen Sie in Ihrer Bereitstellungspipeline drizzle-kit migrate aus, bevor Sie die Anwendung starten, um sicherzustellen, dass das Schema synchronisiert ist.

Wie gehe ich mit dem Datenbank-Seeding mit Drizzle um?

Erstellen Sie eine separate Seed-Datei und importieren Sie Ihr Schema direkt. Verwenden Sie onConflictDoNothing() für idempotente Seeds, die bei einer erneuten Ausführung nicht fehlschlagen. Speichern Sie Seed-Daten in Konstanten für einfache Aktualisierungen und führen Sie Seeds nach Migrationen in Ihrem Entwicklungs-Setup-Skript aus.

Wie führe ich eine Volltextsuche mit Drizzle und PostgreSQL durch?

Verwenden Sie das Vorlagenliteral sql mit den Funktionen to_tsvector und to_tsquery von PostgreSQL. Drizzle abstrahiert die Volltextsuche nicht, aber Sie können sie direkt schreiben. Fügen Sie aus Leistungsgründen einen GIN-Index zur Spalte tsvector hinzu und verwenden Sie websearch_to_tsquery für vom Benutzer bereitgestellte Suchbegriffe, um Abfragen in natürlicher Sprache sicher zu analysieren.

Wie gehe ich mit Schemaänderungen ohne Ausfallzeiten um?

Verwenden Sie additive Migrationen – fügen Sie zuerst Spalten hinzu, die nullbar sind, füllen Sie Daten auf und fügen Sie dann die NOT NULL-Einschränkung hinzu. Löschen Sie niemals Spalten in derselben Migration, die sie aus dem Code entfernt. Die Reihenfolge ist: 1) Code bereitstellen, der die alte Spalte ignoriert, 2) Migration bereitstellen, um sie zu löschen. Dadurch wird sichergestellt, dass ein Rollback immer ohne Datenverlust möglich ist.


Nächste Schritte

Eine gut gestaltete Datenbankschicht ist die Grundlage, auf der jede Produktionsanwendung aufbaut. Das Engineering-Team von ECOSIRE arbeitet täglich mit Drizzle ORM und PostgreSQL und verwaltet über 65 Schemadateien in einem komplexen mandantenfähigen System.

Ganz gleich, ob Sie Beratung zur Datenbankarchitektur, Odoo ERP-Integration oder ein komplettes Backend-System benötigen, das mit modernen TypeScript-Tools erstellt wurde: [Entdecken Sie unsere Dienste] (/services), um zu erfahren, wie wir Ihnen helfen können.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp