flowchart LR
R["Repo<br/>migrations/*.sql<br/><i>versionados con el código</i>"] --> M["herramienta migrate"]
M --> SM["tabla schema_migrations<br/><i>¿qué ya se aplicó?</i>"]
SM -->|pendientes, en orden| DB[("PostgreSQL<br/>esquema evoluciona<br/>sin perder datos")]
20 11. Necesitamos cambiar el esquema sin rezar
La BD de producción ya tiene datos; DROP TABLE no es una opción
Hay que agregar due_date a tasks — pero la BD de producción ya tiene 50.000 filas y no puedes recrearla. La respuesta de la industria: migraciones — el esquema como archivos de código versionados que se aplican en orden, uno por uno, y nunca se editan una vez aplicados.
20.1 El problema
En dev borras la tabla y la recreas — total, los datos eran de prueba. En producción eso es un despido: la tabla tiene datos reales que no puedes perder. Necesitas cambiar la forma sin destruir el contenido: ALTER TABLE en vez de DROP TABLE. Y necesitas que ese cambio sea repetible — que la BD de Ana, la de CI y la de producción evolucionen
Producción (prod) es el entorno real: donde están los usuarios, los datos que importan y las consecuencias. Todo lo demás — local, staging — existe para que los errores ocurran antes de llegar ahí.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
exactamente igual.
20.2 Cómo lo resuelve un equipo
El patrón universal: cada cambio de esquema es un archivo con timestamp, guardado en el repo junto al código, que se aplica una sola vez y en orden:
Un repositorio (repo) es la carpeta del proyecto con todo su historial Git adentro. GitHub/GitLab son servicios que hospedan repos para compartirlos y respaldarlos.
El esquema es el plano de la base de datos: qué tablas hay, qué columnas tiene cada una y de qué tipo. Es contrato: una fila que no cumple el esquema no entra. Se cambia con migraciones, no a mano.
migrations/
20240101000000_initial.up.sql -- create users, tasks, tags, task_tags
20240101000000_initial.down.sql -- how to undo it
20240115000000_add_due_date.up.sql -- ALTER TABLE tasks ADD due_date
20240115000000_add_due_date.down.sql
La herramienta (golang-migrate, alembic, prisma migrate, goose) lleva una tabla especial en la propia BD — schema_migrations — que registra qué archivos ya se aplicaron. Correr migrate up aplica solo los pendientes, en orden. Así las tres BDs (dev, CI, prod) convergen al mismo esquema sin que nadie recuerde qué corrió a mano.
El flujo profesional: generar migración → revisarla como código (PR) → CI la aplica a la BD de pruebas → deploy la aplica a producción. La migración viaja con el código que la necesita — mismo commit.
Un deploy es poner tu código a correr en el servidor: copiar la imagen, iniciar el proceso, verificar que responde. El objetivo es que sea aburrido — automático, repetible, con rollback.
Un commit es una foto guardada del código con un mensaje que dice por qué cambió. La historia del proyecto es una cadena de commits: puedes volver a cualquiera, ver qué cambió y quién lo hizo.
Una migración es un cambio versionado del esquema: un archivo que dice “crea esta columna” (up) y “bórrala” (down). Son el historial Git de la estructura de la BD — se aplican en orden y no se editan una vez aplicadas.
20.3 Conceptos nuevos
- D Migración:
up/down, diffs del esquema como código versionado - D
golang-migratecon.sqlplano — las migraciones son de la BD, no del framework (los tres stacks las comparten) - U
schema_migrations: la tabla que registra qué se aplicó - U Regla de oro: una migración aplicada no se edita jamás — se crea otra que la corrige
- D Migraciones destructivas:
ALTER ... TYPE,DROP COLUMN— cómo se hacen en prod sin perder datos
20.4 La explicación visual
20.5 Implementación
Con golang-migrate (la misma herramienta sirve para los tres stacks — lee .sql plano, no es de Go aunque el nombre confunda):
# create a migration pair
migrate create -ext sql -dir migrations -seq add_tasks_due_date
# → migrations/000002_add_tasks_due_date.up.sql
# → migrations/000002_add_tasks_due_date.down.sql-- 000002_add_tasks_due_date.up.sql
ALTER TABLE tasks ADD COLUMN due_date TIMESTAMPTZ;
-- additive and safe: existing rows get NULL, nothing breaks-- 000002_add_tasks_due_date.down.sql
ALTER TABLE tasks DROP COLUMN due_date;# apply everything pending — to any environment
migrate -path migrations -database "$DATABASE_URL" up
migrate -path migrations -database "$DATABASE_URL" version # where am I?¿Y el cambio peligroso? Renombrar una columna o cambiar su tipo en una tabla con millones de filas no cabe en un ALTER directo (bloquea la tabla). La técnica profesional es expand-contract: agregar la columna nueva → escribir en ambas → migrar datos en lotes → borrar la vieja. Varias migraciones pequeñas y seguras en vez de una grande y bloqueante.
Quiero migraciones versionadas para mi esquema PostgreSQL con
golang-migrate (SQL plano, independiente del lenguaje de mi app). Dame:
la migración inicial con mis tablas (users, tasks, tags, task_tags con
sus constraints), una segunda migración que agregue tasks.due_date, los
archivos .down correspondientes, y los comandos para crear/aplicar/
revertir/ver versión. Además explícame cómo haría un cambio destructivo
(renombrar tasks.title → tasks.name) en producción sin downtime con la
técnica expand-contract, en varias migraciones.
20.6 ¿Por qué el esquema va en el repo?
Lo único que necesitas llevarte: la BD es estado que el código asume — si el código dice tasks.due_date y la BD no la tiene, todo rompe. Versionar el esquema con el código significa que commit X + migraciones hasta X = estado reproducible en cualquier máquina. La BD deja de ser un artefacto misterioso y pasa a ser parte del proyecto.
El estado es todo lo que el programa recuerda: las variables, la sesión, lo que muestra la UI. Stateless (sin estado) = el servidor no recuerda nada entre requests — cada request trae todo lo necesario.
| Alternativa | Cómo trabaja | Qué te cuesta | Cuándo elegirla |
|---|---|---|---|
golang-migrate (.sql plano) |
Tú escribes SQL up/down | Escribes el SQL tú (¿y eso es malo?) | Stacks mixtos, control total — el del libro |
| Alembic (Python) | Autogenera diff comparando modelos vs BD | El autogenerate a veces miente — hay que revisar | Proyectos Python/SQLAlchemy |
prisma migrate (TS) |
Migra desde el schema.prisma | Casada con Prisma | Si ya elegiste Prisma como ORM |
goose / dbmate |
.sql con pragmas en comentarios |
Similar a migrate | Alternativas igual de sanas |
Patrón: las herramientas framework migran “desde el modelo” (la BD adivina el diff); las de .sql plano te hacen escribirlo — y escribirlo es saber qué le pasa a tus datos.
20.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| Editar una migración ya aplicada | “Era solo un typo” → dev y prod divergen | Nueva migración que corrige — el historial es inmutable |
ALTER destructivo directo en prod |
Bloquea la tabla minutos/horas | Expand-contract en pasos |
Migración sin .down |
“Nunca se revierte” — hasta el día que sí | Toda migración tiene su undo (aunque sea SELECT 1 documentado) |
| Correr migraciones a mano en prod | “Yo lo hice” no es un proceso | Las aplica el deploy/CI, mismo comando en todos lados |
| Código nuevo antes de migración | El handler usa due_date que aún no existe |
Orden: migrar → deployar código |
20.8 Buenas prácticas
- Migración y código en el mismo PR — la columna viaja con el handler que la usa.
- Solo aditivas mientras puedas:
ADD COLUMNyCREATE TABLEson seguras;DROP/ALTER TYPEson las que piden expand-contract. - Revisa el SQL del autogenerate — Alembic/Prisma generan SQL que casi siempre está bien; “casi” en producción es un incidente.
- Practica el
down— aplicar y revertir en dev antes de prod.
20.9 Ejercicio
- Escribe el par up/down de la migración “agregar tabla
comments” del ejercicio del cap. 8. - Ordena este deploy: (a) correr migración, (b) deployar código que usa la columna nueva, (c) deployar código que deja de usar la vieja.
- ¿Por qué
migrateguardaschema_migrationsen la propia BD en vez de un archivo?
-- up CREATE TABLE comments ( id BIGSERIAL PRIMARY KEY, public_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- down DROP TABLE comments;- → (b) → (c): primero la migración aditiva (la columna existe, nada la usa aún — seguro), luego el código que la usa, y en otro deploy posterior el código/migración que retira la vieja. El orden evita la ventana donde el código pide columnas inexistentes.
- Porque el registro debe vivir con la BD a la que describe: si fuera un archivo, cada entorno necesitaría el suyo sincronizado y cualquiera podría editarlo. En la tabla, la propia BD dice “estoy en la versión 7” — inambiguo y viajero (un dump la incluye).
20.10 Mini reto
Agregar una columna con DEFAULT a una tabla “con millones de filas” — ¿qué consideraciones tiene en Postgres moderno?
Desde Postgres 11, ADD COLUMN ... DEFAULT x es instantáneo — la BD guarda el default como metadato y no reescribe las filas. La trampa histórica (versiones viejas reescribían toda la tabla: minutos de bloqueo) sobrevive en la memoria colectiva. Lo que sí sigue siendo caro: una columna con default volátil (DEFAULT now() evaluado por fila), un NOT NULL sin default (obliga a reescritura/validación), o un ALTER TYPE que cambia el formato de almacenamiento. Pregunta de entrevista disfrazada de mini reto: “¿cuánto tarda?” → depende de la versión y del tipo de default — por eso se prueba en staging con datos reales.
20.11 Vocabulario técnico del capítulo
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un registry es el almacén de imágenes (Docker Hub, ECR, Artifact Registry): el CI empuja nexus:v42, el servidor la jala. Es el intermediario entre “se construyó” y “está corriendo”.
Staging es el ensayo general: una copia de producción (misma config, datos falsos o anonimizados) donde pruebas el deploy antes de hacerlo de verdad. Si falla ahí, falló gratis.
Un lock de fila (SELECT ... FOR UPDATE) dice “esta fila es mía hasta que mi transacción termine”: otros que la pidan esperan. Es como el candado del probador de ropa — evita que dos editen lo mismo.
Un diff es la lista de diferencias entre dos versiones: “línea 3 cambió de A a B”. Git lo usa para mostrar cambios; los docs colaborativos para transmitir ediciones sin mandar todo el documento.
Una constraint es una regla que la BD hace cumplir: NOT NULL, UNIQUE, CHECK (price > 0), FOREIGN KEY. Es la última línea de defensa — el código puede tener bugs; la constraint no perdona.
Una traza sigue un request a través de todo el sistema: entró por el gateway → llamó auth → consultó la BD → tardó 340ms en la query. Cuando algo anda lento, la traza dice exactamente dónde.
Un entorno es una instancia completa donde corre tu app con su propia config y datos: local (tu máquina), staging (réplica de prueba), producción (la real). Cada entorno tiene sus propias llaves y su propia base de datos.
Un framework es un esqueleto de aplicación ya decidido: te da la estructura (rutas, validación, errores) y tú llenas la lógica. Diferencia con librería: la librería la llamas tú; el framework te llama a ti.
Un proceso es un programa en ejecución: el sistema operativo le da memoria propia y un lugar en la fila del CPU. Tu API es un proceso; Postgres es otro; el navegador es varios. Que “se caiga el proceso” = el programa murió.
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
Un ORM (Object-Relational Mapper) traduce entre objetos del código y filas de la tabla: task.save() en vez de INSERT. Cómodo para el CRUD, peligroso si no sabes qué SQL genera (el N+1 nace ahí).