Hemos visto la misma escena demasiadas veces. Un equipo arranca un proyecto, alguien dice "empecemos con Mongo porque todavía no sabemos cómo serán los datos", y dieciocho meses después estamos reescribiendo migraciones a mano, validando tipos en código de aplicación, y descubriendo que tres servicios distintos guardan el mismo cliente con tres formas distintas. El argumento del "schemaless" casi nunca es una ventaja real: es deuda diferida con intereses altos. La forma de los datos siempre existe; lo único que decides es si vive en el motor o en la cabeza de quien escribió el último endpoint.
Nuestra postura es simple: empezamos con Postgres salvo que tengamos una razón muy concreta para no hacerlo. Y la razón concreta rara vez aparece.
Postgres tiene JSONB desde hace más de una década, y sus operadores cubren prácticamente todo lo que harías en Mongo. El operador -> extrae un campo manteniéndolo como JSON, ->> lo extrae como texto, @> pregunta si un documento contiene a otro, y jsonb_path_exists evalúa expresiones JSONPath completas. Una tabla típica nuestra mezcla columnas tipadas con una columna metadata jsonb: el id, el email, el created_at viven como columnas reales con sus constraints, y todo lo verdaderamente variable, los flags por cliente, la configuración de UI, las preferencias raras, va al JSONB con un índice GIN encima. Tienes lo mejor de ambos mundos sin renunciar a integridad referencial.
Los arrays nativos son otro recurso infravalorado. Cuando una entidad tiene una lista corta y acotada de etiquetas, roles, o IDs relacionados que casi siempre lees junto con la fila, un text[] o uuid[] con unnest y ANY rinde mejor que una tabla de unión y deja el modelo más legible. No siempre, claro: si necesitas atributos sobre la relación, vuelves a la tabla de unión. Pero la regla "todo relación N:M es una tabla nueva" es dogma, no ingeniería.
El full-text search es donde Postgres se separa de verdad. tsvector con diccionarios por idioma, combinado con pg_trgm para similitud por trigramas, resuelve búsquedas en español, catalán, francés, alemán o italiano con stemming y tolerancia a errores tipográficos que el índice de texto de Mongo simplemente no alcanza. Para europeos con tildes, eñes y composición germánica, esto no es un detalle: es la diferencia entre una búsqueda usable y una que el cliente abandona.
Encima ponemos generated columns. Una columna search tsvector GENERATED ALWAYS AS (to_tsvector('spanish', coalesce(title,'') || ' ' || coalesce(body,''))) STORED con un GIN encima nos da búsqueda instantánea sin triggers, sin lógica en aplicación, sin oportunidad de que se desincronice.
Las transacciones multi-documento existen en Mongo desde hace años, pero siguen siendo torpes: requieren replica set, tienen límites de tiempo más agresivos, y la API es incómoda. En Postgres una transacción es una transacción y abarca todo lo que toques. Cuando el dominio tiene invariantes que cruzan entidades, y casi todos los dominios reales los tienen, esto importa.
La realidad operativa cierra el caso. Point-in-time recovery con WAL, replicación lógica para mover datos entre versiones sin downtime, extensiones maduras, y ORMs como Prisma, Drizzle o SQLAlchemy que llevan años puliéndose. El ecosistema de Mongo es bueno, pero el de Postgres es más profundo y más antiguo.
CUÁNDO MONGO GANA DE VERDAD. No estamos diciendo que Mongo no sirva. Sirve, y bien, en escenarios concretos: documentos con formas profundamente variables donde cada uno es genuinamente distinto, como el historial de borradores de un CMS donde cada versión puede tener una estructura propia; cargas write-heavy de series temporales con TTL automático donde la simplicidad de inserción importa más que las queries complejas; o cuando el resto de la organización ya corre sobre Mongo y la coherencia operativa pesa más que la elección técnica óptima.
Los esquemas no son burocracia. Son la forma más barata de documentación que existe. Cada columna con su tipo y su constraint es una frase que no tienes que escribir en un wiki que nadie lee. Empezar sin esquema no te ahorra trabajo; te lo aplaza y te lo cobra con intereses cuando el equipo ya es el doble de grande y nadie recuerda por qué ese campo a veces es string y a veces array.