Cuándo un ORM es la herramienta equivocada
Cuando un ORM se vuelve un cuello de botella, usa SQL bruto para consultas N+1, funciones de ventana, CTE, escrituras masivas y parámetros seguros.
Un ORM es la opción por defecto correcta para el CRUD, y la herramienta equivocada en el momento en que tu consulta deja de parecerse a un acceso a objetos y empieza a parecerse a un informe.
Probablemente conozcas ese momento de inflexión: un endpoint de listado que iba bien en staging tarda cuatro segundos en producción, y el log de consultas está lleno de SELECTs casi idénticos que nadie escribió a mano. Las funciones de ventana, las CTE, las agregaciones con múltiples joins y los operadores específicos de cada proveedor son exactamente el terreno donde el SQL generado por un ORM se vuelve ineficiente o imposible, y donde bajar a SQL en crudo se gana su sitio. Este artículo traza la línea con precisión: dónde el mapeo objeto-relacional es la opción por defecto correcta, dónde se convierte silenciosamente en el cuello de botella y cómo ir más allá sin renunciar a la protección frente a inyecciones.
Puntos clave
- Los ORM son la opción por defecto correcta para el ~80% del CRUD simple: reducen el código repetitivo, parametrizan las entradas automáticamente y se mantienen agnósticos respecto a la base de datos.
- El SQL en crudo no es intrínsecamente más rápido que un ORM. Gana concretamente cuando la consulta generada por el ORM es el cuello de botella: en rutas críticas, operaciones masivas o patrones N+1.
- El problema N+1 es la forma más habitual en que un ORM se convierte silenciosamente en la herramienta equivocada; resuélvelo primero con eager loading y recurre a SQL en crudo solo cuando incluso la forma con eager loading resulte inadecuada.
- Salir del ORM no significa eliminarlo. Baja a SQL en crudo mediante su propia vía de escape:
connection.cursor()oManager.raw()en Django,text()en SQLAlchemy, TypedSQL en Prisma. - Cuando escribes SQL en crudo heredas la responsabilidad de la protección frente a inyecciones, así que pasa siempre la entrada del usuario a través de marcadores de posición (
%sen psycopg,$1en Postgres/SQLx) y nunca la concatenes dentro de la cadena de la consulta.
SQL en crudo, query builders, ORM: un espectro de abstracción
«ORM vs. SQL en crudo» nunca fue una cuestión binaria. El acceso a datos es un espectro que va del control total a la comodidad total, con un nivel intermedio que la mayoría de comparativas se salta. En un extremo, el SQL en crudo te da el lenguaje nativo de la base de datos sin capa de traducción. En el otro, ORM como el de Django, ActiveRecord, Hibernate, Prisma, Sequelize y SQLAlchemy mapean filas a objetos y generan SQL por ti. En medio están los query builders.
Un query builder formaliza patrones de consulta como métodos encadenables sin alejarse del SQL que emite. La mayoría de ORM también exponen alguna forma de entregar a la base de datos una cadena en crudo, lo que elimina el escapado que sus métodos de consulta habituales hacen por ti y vuelve a abrir la puerta a la inyección SQL. Un builder es una herramienta distinta: compone SQL de forma programática sin pretender ser un acceso a objetos. Knex es un query builder de JavaScript con mantenimiento activo, en la línea 3.3.0 desde junio de 2026 según su changelog; en la JVM, jOOQ es un DSL de SQL con tipado seguro, actualmente en la línea 3.21, cuya Open Source Edition apunta a JDK 21. Ninguno es un ORM, y ambos mantienen intacta la parametrización, que es precisamente la clave. Cuando la abstracción del ORM se te resiste, el nivel del builder suele ser el paso intermedio adecuado antes del SQL escrito a mano.
Discover how at OpenReplay.com.
¿Cuándo es un ORM la herramienta equivocada?
La señal para cambiar no es una intuición: es concreta. Ve más allá del ORM cuando te encuentres con uno de estos cinco patrones:
- Consultas analíticas y con forma de informe. Las funciones de ventana, las CTE recursivas, los rollups con
GROUP BY ... HAVINGy los informes con múltiples joins son el terreno donde el SQL generado se vuelve ineficiente o imposible de expresar. Un ORM optimiza para el acceso a objetos, no para salidas de tipo OLAP. - Rutas críticas y operaciones masivas. En un endpoint de mucho tráfico o en un
UPDATE/INSERTpor lotes, las llamadas asave()fila a fila y los viajes de ida y vuelta adicionales se acumulan. Una única sentencia basada en conjuntos sustituye a cientos de escrituras del ORM. - La trampa de las consultas N+1. Se trata en detalle más abajo: el fallo de rendimiento más habitual de los ORM.
- Funcionalidades específicas de la base de datos. Los operadores JSONB de Postgres como
@>y->>, la búsqueda de texto completo contsvector/tsquery, los joinsLATERALy las funciones geoespaciales de PostGIS son características que muchos ORM no pueden expresar del todo o de forma idiomática. Algunos ORM ofrecen utilidades (contrib.postgresen Django), pero la cobertura es parcial. - Comportamiento opaco, «mágico». Cuando no puedes ver ni ajustar el SQL que emite el ORM, depurar y optimizar el rendimiento se convierte en adivinar. Ahí es donde el desajuste de impedancia objeto-relacional aparece como un coste real, y tiene una vertiente de seguridad: los métodos de consulta en crudo que ofrecen la mayoría de ORM quedan fuera de su propio escapado, así que interpolar un valor en uno de ellos te deja expuesto.
El problema de las consultas N+1, con nombre y solución
El problema N+1 es la forma más habitual en que un ORM se convierte silenciosamente en la herramienta equivocada: el lazy loading dispara una consulta por fila, de modo que una lista de 100 elementos se convierte sin avisar en 101 viajes de ida y vuelta. El bucle parece inocente:
# One query for authors, then one MORE per author for their books
for author in Author.objects.all():
print(author.name, author.books.count())
La solución es el eager loading, no el SQL en crudo. select_related y prefetch_related de Django condensan esos viajes en un JOIN o en una única consulta IN:
# Two queries total, regardless of author count
authors = Author.objects.prefetch_related("books")
Resuelve primero el N+1 con eager loading y recurre a SQL en crudo solo cuando incluso la forma con eager loading resulte inadecuada, por ejemplo cuando necesitas un agregado con ventana por autor que el ORM expresaría como otro viaje de ida y vuelta. Las consultas ineficientes del ORM rara vez se anuncian en tu código; se manifiestan como respuestas lentas de la API y páginas que tardan en cargar. Una herramienta de session replay como OpenReplay muestra la petición de red lenta en la línea temporal de la sesión, señalándote el endpoint cuya consulta de backend necesita atención: la ubicación del síntoma, no la consulta en sí. Para profundizar en este equilibrio, consulta la guía de OpenReplay para prevenir la inyección SQL.
¿A qué renuncias cuando escribes SQL en crudo?
Cuando escribes SQL en crudo heredas la única tarea que el ORM hacía por ti en silencio: la protección frente a inyecciones. Pasa siempre la entrada del usuario a través de marcadores de posición de parámetros y nunca la concatenes dentro de la cadena de la consulta. La guía de Django sobre cómo ejecutar consultas SQL en crudo explica la mecánica: cursor.execute() recibe marcadores %s más una lista separada de valores, y el driver escapa cada valor al entrar, de modo que nunca pasa a formar parte del texto de la sentencia.
# Safe: %s is the psycopg/DB-API placeholder, not string formatting
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM book WHERE author = %s", [user_input])
rows = cursor.fetchall()
Deja los marcadores sin adornos: entrecomillar %s dentro de la cadena SQL tira por tierra esa protección. SQLx de Rust toma su marcador de la base de datos, así que $1 en Postgres pero ? en MySQL, MariaDB y SQLite. Más allá de las inyecciones, también asumes más código repetitivo, un acoplamiento más estrecho a un único dialecto SQL y el mapeo manual de las filas de resultados de vuelta a objetos.
No tienes que renunciar a la red de seguridad para renunciar al ORM. Las herramientas con verificación en tiempo de compilación la mantienen: SQLx (0.9) verifica las consultas contra el esquema antes de que la aplicación se ejecute y su propia documentación afirma que no es un ORM; jOOQ (3.21) hace lo mismo en la JVM. Los query builders se sitúan en medio. «ORM vs. SQL en crudo» es un binario falso: el eje real es cuánta abstracción merece cada consulta concreta.
El veredicto pragmático entre ORM y SQL en crudo
Usa el ORM para el ~80% del CRUD simple y baja a SQL en crudo a través de su propia vía de escape para las consultas concretas que lo justifiquen. Salir del ORM no significa eliminarlo. Django documenta tres vías: RawSQL para insertar un fragmento parametrizado dentro de una consulta del ORM, Manager.raw() para una consulta en crudo que aun así devuelve instancias del modelo, y connection.cursor() para saltarse por completo la capa de modelos. SQLAlchemy expone text(); Prisma ofrece TypedSQL, actualmente una funcionalidad en preview, además de $queryRaw para acceso sin tipado.
La decisión se reduce a una tabla breve:
| Situación | Recurre a |
|---|---|
| CRUD, formularios, relaciones estándar | ORM |
| La portabilidad entre dialectos importa | ORM o query builder |
| Informes con múltiples joins, funciones de ventana, CTE | SQL en crudo |
| Endpoint crítico o escritura masiva | SQL en crudo |
| N+1 en una vista de listado | Eager loading primero, SQL en crudo si hace falta |
| Funcionalidad del proveedor que el ORM no sabe nombrar | SQL en crudo |
El SQL en crudo no es una reescritura; es una vía de escape puntual para el puñado de consultas en las que el SQL generado es el cuello de botella. Mantén el ORM como opción por defecto, perfila el endpoint lento e introduce SQL parametrizado escrito a mano exactamente allí donde el plan de ejecución demuestre que está justificado, y en ningún otro sitio.
Preguntas frecuentes
¿Es el SQL en crudo realmente más rápido que un ORM?
No de forma intrínseca. Una consulta del ORM bien escrita y una consulta en crudo bien escrita llegan al mismo planificador de consultas, así que el SQL en crudo no es automáticamente más rápido. El SQL en crudo gana concretamente cuando la consulta generada por el ORM es el cuello de botella: viajes de ida y vuelta adicionales, patrones N+1, selects amplios y sin límite, o rutas críticas donde las sentencias basadas en conjuntos sustituyen a las escrituras fila a fila. La ventaja de velocidad viene de corregir el SQL generado deficiente, no del SQL en crudo en sí.
¿Cuál es la diferencia entre un query builder y un ORM?
Un query builder compone SQL de forma programática mediante métodos encadenables sin alejarse del SQL que emite; no mapea filas a objetos. Un ORM mapea filas de la base de datos a objetos del lenguaje y oculta el SQL por completo. Knex es un query builder para JavaScript y jOOQ es un DSL de SQL con tipado seguro para la JVM: ninguno es un ORM. Ambos mantienen intacta la parametrización, así que prescindes de la abstracción de mapeo a objetos sin perder la protección frente a inyecciones.
¿Cómo escribo SQL en crudo sin exponer mi aplicación a la inyección SQL?
Pasa todos los valores proporcionados por el usuario a través de marcadores de posición de parámetros y nunca concatenes la entrada dentro de la cadena de la consulta. En Django con psycopg el marcador es %s, y el driver de la base de datos escapa los parámetros automáticamente; SQLx de Rust toma su marcador de la base de datos, así que $1 en PostgreSQL pero ? en MySQL, MariaDB y SQLite. No añadas comillas alrededor de los marcadores dentro de la cadena SQL. Las herramientas con verificación en tiempo de compilación como SQLx comprueban las consultas contra el esquema antes de que la aplicación se ejecute, añadiendo una capa de seguridad adicional.
¿Puedo usar SQL en crudo dentro de un ORM sin eliminar el ORM?
Sí. Todos los ORM importantes ofrecen una vía de escape que te permite ejecutar SQL en crudo manteniendo el ORM como opción por defecto. Django ofrece connection.cursor() para ejecución directa, Manager.raw() para devolver instancias del modelo y RawSQL para fragmentos parametrizados dentro de consultas del ORM; SQLAlchemy expone text(); Prisma ofrece TypedSQL como funcionalidad en preview además de $queryRaw para acceso sin tipado. Usa el ORM para el CRUD estándar y atraviésalo hacia el SQL en crudo solo para las consultas concretas en las que el SQL generado sea el cuello de botella.