Tips & Tricks (Aktualisiert: 2.6.2026)

Drizzle ORM mit Claude Code: PostgreSQL, Migrationen, Zod und CI

Drizzle ORM mit Claude Code umsetzen: typisiertes Schema, Migrationen, Seed, Transactions, Zod und CI-Pruefung.

Drizzle ORM mit Claude Code: PostgreSQL, Migrationen, Zod und CI

Drizzle ORM ist ein TypeScript-first ORM, das nahe an SQL bleibt. Tabellen werden in TypeScript beschrieben, Abfragen sehen aber weiterhin wie SQL aus. Genau deshalb eignet es sich gut fuer Claude Code: Der Agent kann das Schema lesen, Relationen verfolgen, Migrationen erzeugen und erklaeren, welcher Index welche Abfrage stuetzt.

Die Gefahr liegt darin, dass Datenbankarbeit zu frueh fertig aussieht. Ein schema.ts kann kompilieren, waehrend die erzeugte Migration eine Spalte loescht. Ein Seed kann einmal funktionieren und beim zweiten Lauf an einem Unique Constraint scheitern. Eine Transaction kann eine Verbindung offen halten, waehrend sie auf E-Mail, Stripe oder einen Webhook wartet. Deshalb sollte Claude Code nicht nur “Drizzle installieren”, sondern Schema, Migration, Queries, Seed, Zod und CI zusammen liefern.

Aktuelle Details pruefen Sie in den offiziellen Quellen: Drizzle ORM docs, Drizzle Kit docs, Transactions, Drizzle Zod docs und Claude Code docs. Als interne Anschlussartikel passen Prisma ORM, database migration und Zod validation.

Arbeitsablauf

Ein guter Prompt trennt Anforderungen, Output und Review-Punkte.

Implementiere die Datenbankschicht mit Drizzle ORM.

Stack:
- PostgreSQL
- drizzle-orm, drizzle-kit, node-postgres
- Tabellen: User, Post, Category, Comment, AuditLog
- Post status: draft, published, archived
- email und slug sind unique
- Liste nach status, publishedAt, author, category und search
- Beim Loeschen von Post nur Comment und Join-Tabelle cascade
- Beim Loeschen von User keine Posts loeschen

Liefere schema, drizzle.config.ts, queries, transaction, seed, Zod und CI.
flowchart LR
  A["Anforderungen"] --> B["schema.ts"]
  B --> C["drizzle-kit generate"]
  C --> D["SQL Review"]
  D --> E["queries, transaction, seed"]
  E --> F["Zod validation"]
  F --> G["CI checks"]

Schema und Migration

Dieses Schema zeigt die Stellen, die in Reviews zaehlen: unique, Indexe, Relationen und Loeschregeln.

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

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(),
    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",
  {
    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] }),
}));
// 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,
});
npm run db:generate
npm run db:check
npm run db:migrate

Lesen Sie die erzeugte SQL-Datei. Achten Sie auf unerwartete DROPs, NOT NULL ohne Backfill, zu breite Cascades und fehlende Indexe fuer reale Listenabfragen.

Queries, Transaction und Seed

// 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 });
export const db = drizzle(pool, { schema });
// 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) } };
}

Seed-Code muss wiederholbar sein.

// 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: "masa@example.com", 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: { title: "Claude Code Drizzle demo", updatedAt: new Date() } })
    .returning();

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

main().finally(async () => pool.end());

Zod und CI

TypeScript validiert keine laufenden API-Eingaben. Zod sitzt an dieser Grenze.

// 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({
    categorySlugs: z.array(z.string().min(1).max(120)).min(1).max(5),
  });
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

Use Cases, Fehler und CTA

Use CaseWorauf achtenPrompt fuer Claude Code
Redaktions-CMSslug, Status, Published-IndexErklaere jeden Index nach Screen
SaaS Admintenant, Rollen, AuditUser-Loeschung darf Businessdaten nicht entfernen
KursplattformFortschritt, Zugriff, TransactionFortschritt und Audit Log konsistent schreiben
Revenue ContentCTA, Produktpfad, CompletionAnalytics ohne schwächere Constraints einfuegen

Typische Fehler sind: SQL nicht lesen, Seed nicht idempotent machen, externe APIs in Transactions rufen, Zod als Ersatz fuer DB Constraints sehen, Pagination ohne Limit erlauben und Claude Code fremde Migrationen anfassen lassen. Fuer Teams ist die englische Seite Claude Code training and consultation der sinnvolle Einstieg. Danach helfen Supabase integration und CI/CD setup.

In einem kleinen Blog-API-Test war der groesste Gewinn, Claude Code die erzeugte SQL-Migration erklaeren zu lassen. Der erste Entwurf war bei Loeschregeln zu aggressiv. Nach separater Pruefung von onDelete, Indexen und idempotentem Seed war der Diff deutlich besser reviewbar. Drizzle ORM ist schnell und direkt, aber sicher wird es erst durch SQL-Review und CI-Belege.

#Claude Code #Drizzle ORM #database #TypeScript #SQL
Kostenlos

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.