
Cada vez que añades una funcionalidad a una aplicación, tarde o temprano necesitas cambiar la base de datos. Una columna nueva, un índice, una tabla entera. Y si la aplicación ya está en producción con datos reales, ese cambio no puede ser un DROP TABLE y empezar de cero. Necesitas una migración.
En nuestros proyectos usamos Drizzle ORM para gestionar el esquema y las migraciones de la base de datos. Este artículo explica cómo funciona ese flujo en la práctica, desde que decides añadir un campo hasta que el cambio está aplicado en producción sin que nadie pierda un dato. Hablamos de esto con más detalle en testing de migraciones de base de datos.
Una migración es un fichero SQL (o una instrucción equivalente) que describe un cambio incremental en la estructura de la base de datos. Añadir una columna, renombrar una tabla, crear un índice. Cada migración tiene un identificador único y se ejecuta una sola vez, en orden.
La alternativa sería modificar directamente el esquema y dejar que la herramienta lo sincronice con la base de datos (lo que Drizzle llama push). Esto funciona en desarrollo, pero en producción es peligroso. Si la herramienta decide que la forma más limpia de aplicar tu cambio es borrar una columna y recrearla, los datos desaparecen. Las migraciones explícitas te dan control total sobre qué SQL se ejecuta y en qué orden.
Drizzle ORM tiene un enfoque que lo diferencia de la mayoría de ORMs. El esquema se define en TypeScript, pero las migraciones son ficheros SQL puros. No hay ficheros de migración en TypeScript, no hay abstracción sobre el SQL. Lo que ves en la carpeta de migraciones es exactamente lo que se va a ejecutar contra la base de datos.
El flujo completo tiene tres pasos.
Todo empieza en un fichero de esquema donde defines las tablas, columnas, tipos, relaciones e índices. Por ejemplo, si quieres añadir un campo noindex a la tabla de posts para controlar qué artículos aparecen en los buscadores, lo añades directamente al objeto de la tabla.
// drizzle/schema.ts
export const posts = sqliteTable("posts", {
id: text("id").primaryKey(),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
content: text("content"),
// ... campos existentes ...
noindex: integer("noindex", { mode: "boolean" })
.notNull()
.default(false),
});El esquema en TypeScript es la fuente de verdad. Es lo que el ORM usa para inferir tipos, autocompletar consultas y validar en tiempo de compilación que no estás accediendo a una columna que no existe.
Drizzle Kit compara el esquema en TypeScript con el estado de las migraciones anteriores y genera un fichero SQL con la diferencia.
npx drizzle-kit generateEl resultado es un fichero SQL limpio en la carpeta de migraciones.
-- drizzle/migrations/0004_fuzzy_omega_flight.sql
ALTER TABLE `posts` ADD `noindex` integer DEFAULT false NOT NULL;No hay magia. Es un ALTER TABLE estándar que puedes leer, revisar y modificar antes de aplicarlo. Si Drizzle genera algo que no te convence, puedes editarlo a mano. El fichero SQL es tuyo.
En desarrollo puedes ejecutar las migraciones manualmente. En producción, el flujo depende de cómo despliegues. En nuestro caso, las migraciones se ejecutan automáticamente cada vez que el contenedor arranca, justo antes de iniciar el servidor.
#!/bin/sh
# entrypoint.sh
echo "Running database migrations..."
node drizzle/migrate.cjs
echo "Starting server..."
exec "$@"El script de migración usa el migrador de Drizzle, que lee la carpeta de migraciones, compara con una tabla interna de control (__drizzle_migrations) y ejecuta solo las que faltan. Si ya están todas aplicadas, no hace nada. Si hay una nueva, la ejecuta y la registra.
Esto significa que desplegar una funcionalidad que requiere un cambio en la base de datos es tan simple como hacer push al repositorio. El pipeline de despliegue construye la imagen, arranca el contenedor, el entrypoint ejecuta las migraciones pendientes y el servidor arranca con el esquema actualizado.
Usamos Drizzle tanto con SQLite (para proyectos ligeros como este blog o un monitor de salud) como con PostgreSQL (para aplicaciones con más concurrencia y funcionalidades). El flujo de migraciones es idéntico en ambos casos. Cambia el driver y el dialecto SQL generado, pero el ciclo de modificar esquema, generar migración y aplicarla en el entrypoint es el mismo. Hablamos de esto con más detalle en el stack técnico del blog.
La diferencia principal está en lo que el motor permite hacer en un ALTER TABLE. PostgreSQL soporta prácticamente cualquier modificación de esquema (añadir columnas con defaults, renombrar, cambiar tipos). SQLite es más limitado. No puedes renombrar columnas en versiones antiguas, no puedes cambiar el tipo de una columna existente, y las restricciones de clave foránea tienen sus propias reglas. Drizzle Kit conoce estas limitaciones y genera SQL compatible con el motor que estés usando.
Si has trabajado con ORMs en Node.js, probablemente conozcas Prisma. Es la opción más popular y tiene un ecosistema enorme. Drizzle es más reciente y toma decisiones de diseño diferentes. Estas son las que más nos importan en la práctica.
Prisma genera migraciones en SQL, pero su API de consultas es una abstracción propia que no se parece al SQL que ejecuta por debajo. Si necesitas una consulta compleja, acabas usando $queryRaw y pierdes el tipado.
Drizzle toma el camino contrario. Su API de consultas es una traducción casi directa de SQL a TypeScript. Si sabes SQL, sabes Drizzle. Y cuando necesitas SQL puro, lo escribes directamente sin perder los tipos.
// Drizzle: la consulta se parece al SQL que genera
const result = await db
.select({ title: posts.title, count: sql`count(*)` })
.from(posts)
.where(eq(posts.status, "published"))
.groupBy(posts.title);
// Prisma: abstracción propia
const result = await prisma.post.groupBy({
by: ["title"],
where: { status: "published" },
_count: true,
});Prisma requiere ejecutar prisma generate cada vez que cambias el esquema para regenerar el cliente. Es un paso extra en el pipeline y a veces provoca errores de caché o desincronización entre el esquema y el cliente generado. Drizzle no tiene este paso. El esquema en TypeScript es el cliente. Los tipos se infieren directamente del código, sin generación intermedia.
Ambos generan SQL, pero Drizzle lo hace de forma más transparente. La carpeta de migraciones contiene ficheros SQL planos que puedes leer, editar y versionar sin herramientas adicionales. Prisma también genera SQL, pero su flujo está más orientado a que uses prisma migrate como comando central y toques los ficheros lo mínimo posible.
Drizzle no tiene capa de abstracción en tiempo de ejecución. Las consultas se construyen y se envían directamente al driver de la base de datos. Prisma interpone su engine (escrito en Rust) entre tu código y la base de datos, lo que añade latencia y consumo de memoria. En aplicaciones con muchas consultas concurrentes, la diferencia se nota.
Si tu equipo está acostumbrado a pensar en SQL y quiere control total sobre las consultas y migraciones, Drizzle encaja mejor. Si prefieres una abstracción más alta, un ecosistema más maduro y no te importa la capa intermedia, Prisma sigue siendo una opción sólida con una comunidad enorme detrás.
En nuestro caso elegimos Drizzle porque trabajamos mucho con agentes de IA que generan código. Al ser una API que se parece al SQL estándar, los agentes producen consultas correctas con más facilidad que cuando tienen que aprender la abstracción de otro ORM.
Después de gestionar migraciones en varios proyectos en producción, hay un puñado de cosas que nos habría gustado saber desde el principio.
Nunca uses push en producción. drizzle-kit push es cómodo para desarrollo rápido porque sincroniza el esquema sin generar ficheros de migración. Pero en producción necesitas un registro auditable de qué cambios se han aplicado y en qué orden. Las migraciones explícitas te dan eso.
Revisa siempre el SQL generado. Drizzle Kit hace un buen trabajo infiriendo el ALTER TABLE correcto, pero no es infalible. Antes de hacer push, abre el fichero de migración y lee el SQL. Son pocas líneas y te ahorran sorpresas.
Los campos nuevos deben tener defaults. Si añades una columna NOT NULL a una tabla que ya tiene datos, necesitas un valor por defecto. Sin él, la migración falla porque las filas existentes no tendrían valor para esa columna. Drizzle te deja definir el default en el esquema y lo propaga al SQL generado.
Las migraciones van en el repositorio. Los ficheros SQL de migración se versionan junto al código. Cada pull request que cambia el esquema incluye su migración correspondiente. Así, el revisor puede ver exactamente qué SQL se va a ejecutar en producción.
Haz backfill cuando tenga sentido. Si añades un campo que es null por defecto pero quieres rellenarlo con datos derivados de los existentes, hazlo con un script separado después de la migración. No metas lógica de negocio dentro de los ficheros de migración SQL.
SQLite y VACUUM requieren cuidado. Si compactas la base de datos SQLite con VACUUM mientras la aplicación está corriendo, la conexión puede quedar en un estado inconsistente. Si necesitas hacer VACUUM, reinicia el servicio inmediatamente después.
Con este flujo, añadir una funcionalidad que requiere un cambio en la base de datos sigue siempre el mismo patrón. Modificas el esquema en TypeScript, generas la migración, revisas el SQL, haces commit y despliegas. El entrypoint del contenedor se encarga del resto. No hay pasos manuales en producción, no hay ventanas de mantenimiento, no hay scripts que ejecutar por SSH.
Es un flujo simple, predecible y que escala bien desde un blog con SQLite hasta una aplicación con PostgreSQL y decenas de tablas. Y lo más importante, cada cambio queda registrado en un fichero SQL que cualquier desarrollador puede leer y entender sin necesidad de conocer la herramienta.

Cómo está construido ScamDetector por dentro, desde el proxy server-side que protege las claves de API hasta los tres backends de IA intercambiables y las medidas de seguridad.

Cada decisión técnica tiene un porqué. Elegimos Next.js sobre Astro, SQLite sobre PostgreSQL y Docker con Dokploy sobre Vercel. Aquí explicamos las razones y lo que aprendimos por el camino.

Playwright no es solo para tests E2E. En JMO Labs lo usamos como motor completo: 9 fases de comprobación, localizador de 9 estrategias con self-healing, grabación de vídeo, testing responsive con viewports reales y accesibilidad con axe-core.