Упрощаем работу с БД с помощью Drizzle ORM — как выжать максимум из инструмента
- вторник, 26 августа 2025 г. в 00:00:06
 
Привет, я Сергей Маркизов, разработчик диджитал-продакшна Далее. В наших проектах часто использую Drizzle — современную, типобезопасную ORM для TypeScript, которая не усложняет базовую задачу: читать и писать данные. В этой статье расскажу, чем библиотека отличается от других и как с ней работать.
Базы данных являются основным средством обеспечения персистентности современных приложений. Для работы с ними зачастую используются различные ORM-решения, ведь они позволяют избавиться от необходимости написания большого количества шаблонного кода при работе с БД.
Но, несмотря на все свои плюсы, ORM сталкиваются с постоянной критикой из-за своих минусов.
Плохая производительность и неспособность использовать многие возможности конкретных баз данных.
Большие размеры кодовой базы этих решений, как правило, приводят к долгоживущим багам, которые не исправляются годами.
Попытки сделать один инструмент для работы как с SQL, так и с NoSQL базами еще сильнее сужают полноценное использование возможностей СУБД.
Так, на проектах, где требуется построение сложных SQL-запросы, TypeORM быстро теряет все свои преимущества. Внутренние механизмы технологии дают большой оверхед и работают не всегда корректно — даже при использовании QueryBuilder. Более того, с применением этого конструктора увеличивается вероятность возникновения багов из-за отсутствия проверки типов. Особенно при запросах, результаты которых не укладываются в схему данных таблиц.
Все же не стоить хейтить все ORM, попробовав один-два инструмента. Существуют и достаточно легковесные решения для работы с БД. Один из них — Drizzle ORM.
Если вы устали от сложных ORM, которые прячут SQL за слоями абстракций, то вам стоит обратить внимание на Drizzle.
Это легковесный, типобезопасный и максимально близкий к SQL инструмент для работы с базами данных — с широкой поддержкой типизированных запросов с помощью TypeScript.
Drizzle позволяет строить SQL-запросы с проверкой типов на основе описания схемы базы данных и не имеет накладных расходов, присущих многим альтернативным инструментам для работы с БД.
Преимущества Drizzle в сравнении с другими ORM  | ||
Инструмент  | Минусы в работе  | Решения Drizzle ORM  | 
Sequelize  | Слабая поддержка TypeScript, устаревшая архитектура, дублирование описания схем и типов  | В Drizzle типы формируются из схемы, описание структуры — в одном месте  | 
TypeORM  | Много скрытого поведения, нестабильная типизация, громоздкий API  | В Drizzle все явно: схема — это код, запросы — читаемые, поведение — предсказуемое  | 
Prisma  | Высокий порог входа, генерация клиента, большой вес проекта  | Drizzle не требует генерации, подключается за 2 строки, не увеличивает bundle  | 
Knex  | Нет встроенной типизации, всю логику нужно писать руками  | Drizzle дает типы из коробки, но оставляет контроль над структурой и логикой  | 
На текущий момент Drizzle поддерживает следующие SQL-СУБД: PostgreSQL, MySQL, SQLite.
Для создания соединения достаточно указать URL базы данных:
const connection = drizzle(process.env.DATABASE_URL);Но для полноценного использования возможностей типизации TypeScript следует указать схему при создании соединения:
const connection = drizzle(process.env.DATABASE_URL!, {
  casing: 'snake_case', // В случае различий в регистре
  logger: true,         // Можно передать логгер или true для использования логгера по умолчанию
  schema: {
    ...schema,
    ...relations,
  },
});В основе описания структуры базы данных в Drizzle ORM лежат таблицы и отношения между ними:
const customBoolean = customType<{ data: boolean }>({
  dataType() {
    return 'boolean';
  },
});
export const examples = pgTable("examples", {
  id: uuid().primaryKey().defaultRandom(), // Первичный ключ
  createdAt: timestamp().defaultNow().notNull(), // Временные метки создания можно определить таким образом
  someVarchar: varchar({ length: 256 }).unique(),
  someInteger: integer().notNull(),
  someDecimal: decimal(),
  someBoolean: boolean().default(true),
  someArray: varchar({}).array(),
  someJson: json(),
  someJsonb: jsonb(),
  someText: text(),
  someTime: time(),
  someTimestamp: timestamp(),
  someUuid: uuid(),
  someCustomBoolean: customBoolean(),
}, table => [ // Индексы на несколько колонок можно добавить следующим образом
  index('examples_some_idx').on(table.someInteger, table.someTimestamp),
  unique('examples_unique').on(table.someBoolean, table.someText),
]);export const players = pgTable('players', {
  id: uuid().primaryKey().defaultRandom(),
  firstName: varchar({ length: 255 }).notNull(),
  lastName: varchar({ length: 255 }).notNull(),
});
export const playerGameStates = pgTable('player_game_state', {
  id: uuid().primaryKey().defaultRandom(),
  playerId: uuid().notNull().references(() => players.id),
  metadata: jsonb().notNull(),
});
export const playersRelations = relations(players, ({ one }) => ({
  state: one(playerGameStates, {
    fields: [players.id],
    references: [playerGameStates.playerId],
  }),
}));export const positions = pgTable('positions', {
  id: uuid().primaryKey().defaultRandom(),
  name: varchar({ length: 256 }).unique().notNull(),
  responsibilities: varchar({ length: 256 }).array().notNull(),
});
export const employees = pgTable('employees', {
  id: uuid().primaryKey().defaultRandom(),
  firstName: varchar({ length: 256 }).unique().notNull(),
  lastName: varchar({ length: 256 }).unique().notNull(),
  positionId: uuid().notNull().references(() => positions.id),
});
export const positionsRelations = relations(positions, ({ many }) => ({
  employees: many(employees),
}));
export const employeesRelations = relations(employees, ({ one }) => ({
  position: one(positions, {
    fields: [employees.positionId],
    references: [positions.id],
  }),
}));export const customers = pgTable('customers', {
  id: uuid().primaryKey().defaultRandom(),
  firstName: varchar({ length: 256 }).unique().notNull(),
  lastName: varchar({ length: 256 }).unique().notNull(),
});
export const services = pgTable('services', {
  id: uuid().primaryKey().defaultRandom(),
  name: varchar({ length: 256 }).unique().notNull(),
});
export const subscriptions = pgTable('subscriptions', {
  createdAt: timestamp().defaultNow().notNull(),
  isActive: boolean().notNull().default(true),
  customerId: uuid().notNull().references(() => customers.id),
  serviceId: uuid().notNull().references(() => services.id),
}, table => [
  primaryKey({ columns: [table.customerId, table.serviceId] }),
]);
export const customersToServicesSubscriptionRelation = relations(subscriptions, ({ one }) => ({
  customer: one(customers, {
    fields: [subscriptions.customerId],
    references: [customers.id],
  }),
  service: one(services, {
    fields: [subscriptions.serviceId],
    references: [services.id],
  }),
}));Для работы с миграциями в Drizzle ORM используется утилита drizzle-kit. Ее нужно установить отдельно.
npm install drizzle-kit
Набор команд:
npx drizzle-kit migrate # Применить миграции
npx drizzle-kit generate # Сгенерировать миграции на основе текущей схемы и структуры базы данных
npx drizzle-kit push # Актуализировать базу данных в обход механизма миграций (удобно при прототипировании)
npx drizzle-kit pull # Построить файлы схемы и отношений на основе существующей базы данныхКонфигурация инструмента описывается в файле drizzle.config.ts или drizzle.config.js.
import 'dotenv/config'; // Подгружаем переменные окружения
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
  out: './drizzle', // Рабочая директория для миграций и генерации схемы
  schema: './src/drizzle/schema.ts', // Схема, которая будет использоваться для генерации миграций
  dialect: 'postgresql',
  casing: 'snake_case',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});Для валидации передаваемых значений используется TypeScript, для валидации схем данных — расширение drizzle-zod.
Извлечение данных в Drizzle ORM можно осуществлять без построения запросов вручную. Для этого в нем предусмотрено два метода: findMany и findFirst.
const accounts = await postgres.query.accounts.findMany({
    where: eq(schema.accounts.isActive, true),
    limit: 5,
    offset: 100,
    orderBy: [desc(schema.accounts.createdAt)],
});
const account = await postgres.query.accounts.findFirst({
    where: and(
        eq(schema.accounts.isActive, true),
        isNotNull(schema.accounts.email),
    ),
    with: {
        carts: true,
    },
});Для извлечения данных можно воспользоваться встроенным API построения SQL-запросов. По синтаксису он почти не отличается от других query builder'ов, за исключением типизации, которая здесь строго выведена из схемы.
postgres.select().from(schema.stores)
postgres.select({
    id: schema.stores.id,
    title: schema.stores.title,    
})
.from(schema.stores)
.where(eq(schema.stores.isActive, true))Джоины описываются таким же образом, как и в большинстве других конструкторов запросов.
await postgres.select()
.from(schema.customerCarts)
.innerJoin(schema.orders, and(
    eq(schema.orders.cartId, schema.customerCarts.id),
    gte(schema.orders.totalCost, 100),
))Псевдонимы колонок и SQL-выражения можно задать с помощью встроенного в Drizzle ORM оператора sql. Остальные его возможности мы рассмотрим позже.
postgres.select({
  id: sql`${schema.accounts.id}`.as('someId'),
  fullName: sql`${schema.accounts.firstName} || ' ' || ${schema.accounts.lastName}	`.as('fullName'),
}).from(schema.accounts)Подзапросы и CTE описываются схожим образом:
const subquery = postgres.select().from(schema.stores);
const rowsFromSubquery = await postgres.select().from(schema.stores);
const statsCTE = postgres.$with('stats').as(
    postgres.select({
        accountId:  sql`${schema.accounts.id}`.as('accountId'),
        storeId: sql`${schema.stores.id}`.as('storeId'),
        totalCost: sql`sum(${schema.orders.totalCost})`.as('totalCost'),
        count: sql`sum(${schema.customerCartProducts.count})`.as('count'),
    }).from(schema.stores)
    .innerJoin(schema.customerCarts, eq(schema.customerCarts.storeId, schema.stores.id))
    .innerJoin(schema.accounts, eq(schema.accounts.id, schema.customerCarts.accountId))
    .innerJoin(schema.customerCartProducts, eq(schema.customerCartProducts.cartId, schema.customerCarts.id))
    .innerJoin(schema.orders, eq(schema.orders.cartId, schema.customerCarts.id))
    .where(and(
        inArray(schema.stores.title, stores),
        eq(schema.customerCarts.wasOrdered, true)
    ))
    .groupBy(schema.stores.id, schema.accounts.id)
);
const rowsWithCTE = await postgres.with(statsCTE).select().from(schema.accounts).innerJoin(statsCTE, eq(statsCTE.accountId, schema.accounts.id))Drizzle ORM дает возможность проверки корректности типов данных при добавлении в базу — на основе описанной схемы данных. Эти типы можно явно указывать в коде, что позволяет избежать ошибок из-за недостающих данных при подобных операциях. Также можно указать поведение в случае конфликта.
// Сохраним тип черновика, для явного указания
type AccountDraft = typeof schema.accounts.$inferInsert;
const draft1: AccountDraft = {
    firstName: 'John',
    lastName: 'Doe',
};
const draft2: AccountDraft = {
    firstName: 'Robinson',
    lastName: 'Crusoe',
};
// const rows = await postgres.insert(schema.accounts).values(draft1).onConflictDoNothing({ target: schema.accounts.id }).returning();
const rows = await postgres.insert(schema.accounts).values([
    draft1,
    draft2
]).returning().onConflictDoUpdate({
    target: schema.accounts.id,
    set: { firstName: 'Julius', lastName: 'Caesar' },
});
Обновление данных в Drizzle ORM максимально приближено к SQL.
await postgres.update(schema.stores).set({
    isActive: false,
}).where(
    gte(schema.stores.createdAt, new Date('2025-01-01'))
).returning();Удалять данные можно с помощью ранее указанных выражений.
await postgres.delete(schema.players).where(and(
    eq(schema.players.firstName, 'John'),
    eq(schema.players.lastName, 'Doe'),
)).returning();При работе с ORM-библиотекой могут возникнуть ситуации, когда написать конкретный запрос с использованием синтаксиса ORM оказывается сложно.
В подобных случаях стоит прибегнуть к использованию параметризованных сырых запросов raw queries и/или преобразованию объектов из TypeScript к SQL-подобному синтаксису — для указания в параметрах. Для этого в Drizzle ORM существует специальный оператор sql. Ниже рассмотрим основные варианты его использования.
Простой пример использования оператора sql — подстановка в сырой SQL-запрос наименований таблиц и колонок, а также параметров:
await postgres.execute(
    sql`select ${schema.accounts.phone}, ${schema.accounts.email} from ${schema.accounts} where ${schema.accounts.id} = ${accountId}`
)sql``.mapWith()
Для указания правил, по которым стоит преобразовывать те или иные данные, можно воспользоваться конструкцией:
postgres.select({
    id: schema.accounts.id,
    count: sql<number>`count(*)`.mapWith(Number) 
}).from(schema.accounts);sql``.as()
Для того чтобы задать псевдоним для имени, можно взять следующую конструкцию:
sql`sum(store_products.cost)`.as('store_products_total_cost')sql.raw()
Метод sql.raw() нужен тогда, когда требуется защита содержимого от экранирования или любых других преобразований.
Пример из документации:
sql`select * from ${usersTable} where id = ${12}`;
// select * from "users" where id = $1; --> [12]
sql.raw(`select * from "users" where id = ${12}`);
sql`select * from ${usersTable} where id = ${sql.raw(12)}`;
// select * from "users" where id = 12;sql.join()
Метод sql.join() поможет объединить несколько параметризованных запросов. 
sql.join([
    sql`select id, first_name, last_name, 'account' from ${schema.accounts}`,
    sql`select id, first_name, last_name, 'employee' from ${schema.employees}`,
], sql.raw(' union '))
// select id, first_name, last_name, 'account' from "accounts" union select id, first_name, last_name, 'employee' from "employees"Работа с транзакциями в Drizzle ORM реализована на основе вызова callback`а с получением объекта транзакции для осуществления операций с базо
await postgres.transaction(async trx => {
    const cart = await postgres.query.customerCarts.findFirst({
        where: and(
            eq(schema.customerCarts.accountId, accountId),
            eq(schema.customerCarts.wasOrdered, true)
        )
    });
    // Вложенные транзакции тоже поддерживаются
    await trx.transaction(nestedTrx => nestedTrx.query.accounts.findFirst({ where: eq(schema.accounts.id, accountId) }));
    // trx.rollback(); // Можно откатить транзакцию вручную
    // throw new Error('I will rollback this transaction'); // Или через прикидывание ошибки
}, {
    isolationLevel: "read committed", 
    accessMode: "read write",
    deferrable: true,
});
```На момент написания статьи Drizzle ORM не предоставляет встроенной поддержки работы с репликами read/write split. Тем не менее, ее можно реализовать вручную — например, через обертку над drizzle(...), создавая два подключения: одно для записи, другое для чтения.
Примерная структура:
const master = drizzle(process.env.WRITE_DB_URL);
const replica = drizzle(process.env.READ_DB_URL);
// Пишем в master
await master.insert(schema.logs).values({ message: 'hello' });
// Читаем из реплики
const logs = await replica.select().from(schema.logs);Переключение между инстансами базы придется реализовывать на уровне приложения, особенно при использовании транзакций — они должны всегда работать только на master.
Drizzle ORM поддерживает экосистему расширений, которые позволяют упростить интеграцию с другими библиотеками и улучшить developer experience при разработке. Ниже — два наиболее полезных.
drizzle-zod
Пакет drizzle-zod позволяет автоматически генерировать Zod-схемы на основе описанных в Drizzle таблиц. Это удобно для валидации входных данных, например, в API-обработчиках.
import { createInsertSchema } from 'drizzle-zod';
import { users } from './schema';
const insertUserSchema = createInsertSchema(users);insertUserSchema— полноценная Zod-схема, которую можно использовать в API для проверки данных.
drizzle-orm/trpc
Для работы с tRPC можно использовать генерацию типов и схем напрямую из Drizzle — это позволяет избежать дублирования контрактов между бэкендом и фронтом.
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
type AppRouter = typeof appRouter;
type Inputs = inferRouterInputs<AppRouter>;
type Outputs = inferRouterOutputs<AppRouter>;Статически типизированные маршруты + строгие схемы данных на уровне API.
Drizzle можно легко использовать в любом современном TypeScript-приложении. Особенность — он не привязан к фреймворку, но спокойно работает с:
Next.js (App Router) — подключение через серверные обработчики;
Remix — через лоадеры и экшены;
NestJS — можно обернуть в провайдер или сервис, как любой другой клиент базы.
Гибкость Drizzle ORM позволяет внедрять инструмент поэтапно — без жестких требований к архитектуре приложения, но с полной типовой поддержкой на всех уровнях.
Drizzle ORM решает многие проблемы классических ORM: он дает типы, остается ближе к SQL и не навязывает архитектуру. Но у него, как и у любого инструмента, есть свои ограничения. Они особенно заметны тем, кто приходит из мира Prisma или TypeORM.
Drizzle работает только с реляционными СУБД: PostgreSQL, MySQL и SQLite. Если вы ищете поддержку MongoDB, Redis или другой NoSQL — этот инструмент не подойдет.
Drizzle не генерирует схемы «по моделям», не скрывает SQL и не предлагает магии. Это плюс, если вы хотите контролировать все вручную. Но если вы привыкли к удобству Prisma, например, когда можно не думать о связях и все описывается декларативно, то придется переключиться в другой режим работы.
Применение миграций реализовано через drizzle-kit. В рантайме, при старте приложения, автоматическое применение миграций не предусмотрено. Это означает, что в CI/CD-процессе нужно явно учитывать миграции как отдельный шаг.
Drizzle не управляет подключениями к репликам и не делает read/write split. Все переключение между инстансами баз, например, мастер и реплика — на стороне приложения.
Проект активно развивается, и отдельные части экосистемы, например, drizzle-zod, drizzle-studio, могут меняться. Документация не всегда успевает за обновлениями — иногда придется смотреть в исходники.
Drizzle пока не может похвастаться большим количеством гайдов, Stack Overflow-ответов и туториалов. Для некоторых задач придется изобретать свое — зато часто это «чистый» TypeScript без лишней обвязки.
Drizzle ORM — хороший выбор, если вам нужен контроль, типизация и чистый SQL без лишнего слоя между вами и базой. Она не делает все за вас, но это понятный и стабильный инструмент, на который можно положиться в реальной работе.
Напишите в комментариях, если уже пробовали Drizzle. Будет интересно узнать, как вы его используете и с какими задачами он справился или, наоборот, не справился.