Rocíe ORM con PostgreSQL: Guía completa
Drizzle ORM ocupa una posición única en el ecosistema de bases de datos TypeScript: es el ORM que en realidad es solo un generador de consultas disfrazado, con un esquema como código que genera SQL que puede leer y un sistema de migración que trata su base de datos como la fuente de la verdad. Después de años de Prisma y TypeORM, Drizzle se siente como volver a casa con SQL sin formato, pero con inferencia completa de TypeScript y cero sobrecarga de tiempo de ejecución.
Esta guía cubre todo, desde la configuración inicial hasta los patrones de producción, a partir de una base de código con más de 65 archivos de esquema Drizzle, consultas multiinquilino y un patrón de conexión de proxy diferido que funciona de manera confiable en contextos Next.js y NestJS.
Conclusiones clave
- Utilice un proxy diferido para la conexión a la base de datos: nunca cree conexiones ansiosas en el momento de carga del módulo
- PostgreSQL local debe ejecutarse en un puerto no predeterminado (5433) para evitar conflictos con las instalaciones del sistema.
- Todas las tablas necesitan
organizationIdpara multiinquilino: aplicar a nivel de esquema- Las comparaciones de enumeraciones de Drizzle necesitan una conversión explícita de TypeScript para la reducción de tipos
- Nunca use
sql.raw(); use siempre el literal de plantillasqlcon valores parametrizados- Las relaciones están separadas de las claves externas: defina ambas para mayor seguridad de tipo
- Las transacciones envuelven operaciones de varios pasos; pasar el objeto
txa servicios- El índice de esquema (
schema/index.ts) debe exportar todas las tablas para que las migraciones funcionen.
Instalación y configuración
Drizzle requiere dos paquetes: el ORM principal y el controlador de la base de datos.
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg
Para la pila completa, incluidas migraciones y estudio:
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
El paquete postgres (diferente de pg) es el controlador recomendado para Drizzle en proyectos TypeScript modernos: admite agrupación de conexiones, declaraciones preparadas y tiene mejores tipos de TypeScript.
El patrón de proxy diferido
La decisión arquitectónica más crítica con Drizzle en un monorepo Next.js o NestJS es nunca crear una conexión de base de datos entusiasta. Las conexiones ansiosas en el momento de carga del módulo causan problemas en:
- Next.js: los módulos se importan durante el proceso de construcción, donde no hay ninguna base de datos disponible
- NestJS: las importaciones de paquetes ocurren antes de que se carguen las variables de entorno
- Sin servidor: los arranques en frío se retrasan debido a la sobrecarga de la conexión
El patrón Lazy Proxy resuelve todos estos problemas:
// 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';
Este patrón significa que importar @ecosire/db nunca abre una conexión de base de datos. La conexión se crea solo cuando se ejecuta la primera consulta.
Diseño de esquema
Los esquemas de llovizna son archivos TypeScript simples que exportan definiciones de tablas. La disciplina aquí es mantener las tablas relacionadas en el mismo archivo y exportar todo a través de un índice central.
// 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;
Los tipos $inferSelect y $inferInsert le brindan una inferencia completa de TypeScript: Drizzle sabe qué campos son obligatorios y qué campos son opcionales en el momento de la inserción según las restricciones de su esquema.
// packages/db/src/schema/index.ts
export * from './contacts';
export * from './orders';
export * from './licenses';
export * from './products';
// ... all tables
El archivo de índice debe exportar todas las tablas para que drizzle-kit las recoja durante la generación de la migración.
Relaciones
Drizzle separa las restricciones de clave externa (aplicadas por PostgreSQL) de las definiciones de relaciones (utilizadas por la API de consulta relacional de Drizzle). Necesitas ambos:
// 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],
}),
}));
Con las relaciones definidas, puede utilizar la API de consulta relacional de Drizzle:
// 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 genera una única consulta JOIN, sin problema N+1.
Consultas de tipo seguro
La API de consulta de Drizzle es totalmente segura para escribir. El tipo de selección se limita según las columnas que incluya:
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);
Para cláusulas donde dinámicas (filtros de la interfaz de usuario), cree condiciones de forma condicional:
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);
}
Tenga en cuenta la conversión explícita de los valores de enumeración: filters.type as 'individual' | 'company' | 'partner'. El tipo de enumeración de Drizzle no acepta automáticamente subtipos de cadenas más estrechas sin la conversión.
Insertar, actualizar y eliminar
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)
)
);
}
Utilice siempre .returning() en las declaraciones INSERT y UPDATE; evita un segundo viaje de ida y vuelta SELECT para obtener el registro creado/actualizado.
Transacciones
Las operaciones de varios pasos que deben tener éxito o fracasar juntas necesitan transacciones:
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 };
});
}
Al pasar el contexto de la transacción a los métodos de servicio:
// 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();
}
Migraciones con llovizna-kit
La CLI drizzle-kit genera migraciones a partir de cambios de esquema:
// 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,
});
Flujo de trabajo:
# 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
Para producción, utilice siempre migrate (no push). El comando push es para la iteración de desarrollo local; no crea archivos de migración, por lo que no se realiza un seguimiento de los cambios.
Patrón de paginación
La paginación eficiente requiere tanto una consulta de datos como una consulta de recuento:
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),
},
};
}
La ejecución de ambas consultas en paralelo con Promise.all reduce a la mitad el tiempo de respuesta en comparación con la ejecución secuencial.
Errores y soluciones comunes
Error 1: usar sql.raw() para consultas dinámicas
// 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}`
);
Error 2: Faltan índices en columnas consultadas con frecuencia
Agregue siempre índices a las columnas por las que filtra. Los índices perdidos más comunes:
(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
),
})
Error 3: N+1 consultas provenientes de la carga manual de relaciones
// 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 },
});
Error 4: Olvidar exportar nuevas tablas desde esquema/index.ts
Si crea un nuevo archivo de esquema pero olvida volver a exportarlo desde schema/index.ts, drizzle-kit no verá la nueva tabla y no se generarán migraciones.
Preguntas frecuentes
¿Cómo se compara Drizzle con Prisma para proyectos TypeScript?
Drizzle está más cerca del SQL sin formato con tipos TypeScript: escribe consultas que se asignan 1:1 a SQL. Prisma abstrae más, con un lenguaje de consulta personalizado y un archivo de esquema separado. Drizzle tiene una mejor inferencia de TypeScript en consultas complejas, una menor sobrecarga de tiempo de ejecución y no requiere un paso de generación de código. Prisma tiene un ecosistema más grande y más funciones integradas, como eliminaciones temporales y registros de auditoría. Para equipos grandes que se sienten cómodos con SQL, generalmente se prefiere Drizzle.
¿Debo usar drizzle-kit push o migrar en CI/CD?
Utilice siempre drizzle-kit migrate en CI/CD. El comando push es para un desarrollo local rápido: aplica cambios de esquema directamente sin crear archivos de migración, por lo que no hay seguimiento de auditoría y la reversión es difícil. En su canal de implementación, ejecute drizzle-kit migrate antes de iniciar la aplicación para asegurarse de que el esquema esté sincronizado.
¿Cómo manejo la siembra de bases de datos con Drizzle?
Cree un archivo semilla separado e importe su esquema directamente. Utilice onConflictDoNothing() para semillas idempotentes que no fallarán al volver a ejecutarlas. Almacene datos semilla en constantes para facilitar las actualizaciones y ejecute semillas después de las migraciones en su script de configuración de desarrollo.
¿Cómo realizo una búsqueda de texto completo con Drizzle y PostgreSQL?
Utilice el literal de plantilla sql con las funciones to_tsvector y to_tsquery de PostgreSQL. Drizzle no abstrae la búsqueda de texto completo, pero puedes escribirla directamente. Agregue un índice GIN en la columna tsvector para mejorar el rendimiento y utilice websearch_to_tsquery para los términos de búsqueda proporcionados por el usuario para analizar de forma segura consultas en lenguaje natural.
¿Cómo manejo los cambios de esquema sin tiempo de inactividad?
Utilice migraciones aditivas: primero agregue columnas que acepten valores NULL, rellene los datos y luego agregue la restricción NOT NULL. Nunca elimine columnas en la misma migración que las elimina del código. La secuencia es: 1) Implementar código que ignora la columna anterior, 2) Implementar migración para eliminarla. Esto garantiza que la reversión siempre sea posible sin pérdida de datos.
Próximos pasos
Una capa de base de datos bien diseñada es la base sobre la que se construye toda aplicación de producción. El equipo de ingeniería de ECOSIRE trabaja diariamente con Drizzle ORM y PostgreSQL, administrando más de 65 archivos de esquema en un complejo sistema multiinquilino.
Ya sea que necesite consultoría de arquitectura de base de datos, integración de Odoo ERP o un sistema backend completo creado con herramientas modernas de TypeScript, explore nuestros servicios para saber cómo podemos ayudarlo.
Escrito por
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
Artículos relacionados
Migraciones de bases de datos sin tiempo de inactividad con Drizzle ORM
Ejecute migraciones de bases de datos sin tiempo de inactividad utilizando Drizzle ORM. Cubre el patrón de contrato de expansión, cambios de esquema compatibles con versiones anteriores, estrategias de reversión e integración CI/CD para PostgreSQL.
Ajuste del rendimiento de Odoo: PostgreSQL y optimización del servidor
Guía experta para ajustar el rendimiento de Odoo 19. Cubre la configuración, indexación, optimización de consultas, almacenamiento en caché de Nginx y dimensionamiento del servidor de PostgreSQL para implementaciones empresariales.
Consultas de bases de datos en lenguaje natural con OpenClaw
Cómo OpenClaw permite consultas de bases de datos en lenguaje natural, traduciendo preguntas comerciales en inglés sencillo a SQL preciso sin exponer las credenciales de la base de datos ni la complejidad de las consultas.