Drizzle ORM с Postgres в TypeScript: настройка проекта с нуля
Drizzle я подсадил у себя на проде примерно полтора года назад, когда устал бороться с Prisma на длинных миграциях. С тех пор перевёл на него три сервиса разной величины и, кажется, нащупал минимальный набор шагов, после которого с ORM можно реально работать, а не подкручивать её каждый раз перед релизом. Этим набором и поделюсь.
Сразу оговорю: статья про Postgres. Под SQLite и MySQL общая логика та же, но конкретные импорты и типы колонок отличаются. Версии: Node 20, TypeScript 5.4, drizzle-orm 0.30+, drizzle-kit 0.21+, Postgres 16. Если у тебя более ранний drizzle-kit, часть команд будет звучать по-другому, проверь свой package.json.
Зачем вообще Drizzle
Главное, ради чего я к нему пришёл, это контроль над SQL. Drizzle не прячет от тебя запрос: ты пишешь схему на TS, получаешь типизированный builder, и в логе сервера видишь именно тот SQL, который уйдёт в БД. Никакого $queryRaw, чтобы починить план запроса, никаких сюрпризов с N+1 от ленивых relations.
Второй плюс — миграции. Они генерируются как обычные SQL-файлы, которые можно прочитать, поправить руками и положить в репозиторий. Никакой бинарной schema engine, которая решает за тебя, как именно добавлять колонку.
Минус честно тоже один: если ты ждёшь от ORM магических relations с автозагрузкой и одной строкой findMany, в Drizzle чуть больше ручной работы. Зато потом эта работа отливается в предсказуемый SQL.
Установка зависимостей
В пустом проекте делаю так. pg — это драйвер node-postgres, его и беру для серверного приложения. Если у тебя serverless и хочется websocket-pool, есть варианты на postgres и @neondatabase/serverless, но для обычного Node-сервиса pg — самый предсказуемый выбор.
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg tsx dotenvtsx я держу как раннер для скриптов миграции и сидов: запускает TS без отдельного билда, как раз удобно для CLI-задач.
Конфиг drizzle-kit
Создаю файл drizzle.config.ts в корне проекта. Это конфиг для CLI, который генерирует миграции и пушит схему.
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});Параметр strict важен: он не даст молча применить миграцию, в которой drizzle-kit что-то не понял. Лучше один раз увидеть подсказку, чем потом разбирать продовский диф.
Подключение и pool
Подключение я выношу в src/db/index.ts и переиспользую один pool на весь процесс. На каждом запросе создавать клиента — верный способ упереться в лимит соединений Postgres.
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
});
export const db = drizzle(pool, { schema, logger: process.env.NODE_ENV !== 'production' });На max: 10 я обычно ставлю на single-instance API. Если приложений несколько и они шарят одну БД, общая сумма pool-ов всех инстансов не должна превышать max_connections Postgres минус запас на админ-сессии.
Первая схема
Начинаю с самой ходовой пары: пользователи и посты. Положу в src/db/schema.ts:
import { pgTable, serial, text, varchar, timestamp, integer, index, uniqueIndex } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 320 }).notNull(),
name: varchar('name', { length: 120 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (t) => ({
emailIdx: uniqueIndex('users_email_idx').on(t.email),
}));
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
authorId: integer('author_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 160 }).notNull(),
body: text('body').notNull(),
publishedAt: timestamp('published_at', { withTimezone: true }),
}, (t) => ({
authorIdx: index('posts_author_idx').on(t.authorId),
}));Что важно отметить:
varcharдля коротких полей с понятным верхним пределом,textдля тела поста. Postgres хранит их одинаково, ноvarchar(160)— это документация и валидация, доступная и SQL-консоли.withTimezone: trueна временных метках. Без этого получишьtimestamp without time zone, который потом будет давать сюрпризы при джойнах с другими сервисами.- Индекс
users_email_idxуникальный, потому что искать по email мы будем при логине каждый раз.
Первая миграция
Команды я держу в package.json, чтобы не вспоминать аргументы.
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio"
}
}Файл src/db/migrate.ts — простой раннер на основе встроенной утилиты Drizzle. Я не люблю гонять drizzle-kit push на проде: push хорош для локального прототипа, а для деплоя нужны именно миграционные файлы, которые видно в git.
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
async function main() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
const db = drizzle(pool);
await migrate(db, { migrationsFolder: './drizzle' });
await pool.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});Дальше — рутина: pnpm db:generate создаёт SQL в ./drizzle, pnpm db:migrate накатывает на базу. Если поправил схему — повторил пару команд, миграция применилась.
Запросы
Drizzle даёт два уровня API: SQL-builder и query API. Для типичных операций они оба удобны, выбор — вопрос вкуса.
import { eq, desc } from 'drizzle-orm';
import { db } from './db';
import { users, posts } from './db/schema';
export async function getRecentPostsByAuthor(authorId: number, limit = 20) {
return db
.select({
id: posts.id,
title: posts.title,
publishedAt: posts.publishedAt,
})
.from(posts)
.where(eq(posts.authorId, authorId))
.orderBy(desc(posts.publishedAt))
.limit(limit);
}Возвращаемый тип — массив объектов, в котором ровно те поля, что я перечислил в select. Никаких лишних колонок, никаких any, всё обнаружится на этапе компиляции TS.
Что я бы добавил с самого начала
Пара мелочей, которые хочется иметь в проекте сразу, иначе потом приходится переписывать.
- Транзакции через
db.transaction. Заверни любой сценарий с двумя записями в транзакцию, даже если кажется простым. Я ловил рассинхрон междуusersиprofilesровно потому, что когда-то поленился. - Префиксы для индексов. Drizzle сам не умеет проверять уникальность имён индексов — ловишь это уже на миграции. Сразу пиши
posts_author_idx, а неidx, иначе через год их будет двадцать с одним именем. - Запрет на
anyвtsconfig. Drizzle тянет типы из схемы, и если разрешитьanyв репо, половина гарантий уходит в никуда.
Куда копать дальше
Когда базовая часть встала, имеет смысл посмотреть на relations API: он даёт понятный интерфейс для джойнов и встраивается в TS без ручной типизации. Я разбираю его подводные камни в отдельной статье; если резюмировать в строчке — relations сильно облегчают типичный findMany, но плохо дружат с гипертонкими выборками, где важен каждый колонка-байт.
Ещё стоит подключить drizzle-zod, чтобы получать схемы валидации напрямую из таблиц. Удобно для API, не приходится дублировать поля в zod-схемах. И посмотреть на drizzle-kit pull — он умеет делать ровно противоположное генерации, вытащить схему из существующей БД, что выручает при подъёме legacy-проекта.
На этом базовая настройка закончена. У меня этот шаблон лежит в виде стартера, и каждый новый сервис поднимается на нём за полчаса вместе с базой и первой миграцией.