El formateo SQL y la cláusula IN que rompe producción
Publicado el 13/8/2025 · 21 min de lectura · Herramientas para desarrolladores
Daniel Okonkwo — Desarrollador front-end y redactor de Tecnología en OneKitly
Rendimiento web · Formatos de archivo
Verificado con 7 fuentes
Una lista IN construida pegando los valores dentro de una cadena está mal por dos razones independientes. La razón de seguridad es que los valores pasan a formar parte del texto de la sentencia, así que el analizador no puede distinguir datos de sintaxis; la parametrización elimina esa posibilidad de forma estructural y no por filtrado, porque la sentencia se analiza y planifica primero y los valores se enlazan después en huecos ya tipados como valores. Un parámetro enlazado no puede convertirse en un operador, un nombre de tabla ni una segunda sentencia, contenga lo que contenga. Escapar no es equivalente: es una transformación que hay que aplicar correctamente en cada contexto, y un solo descuido restaura el agujero entero. La razón operativa es la que la gente encuentra de verdad. Toda base de datos limita cuántos parámetros puede llevar una sentencia — PostgreSQL y MySQL en 65 535, SQL Server en 2 100 parámetros por procedimiento, Oracle en 1 000 expresiones en una lista hasta 19c y 65 535 desde 23ai — y una sentencia cuyo texto cambia con cada longitud de lista produce una entrada de caché distinta cada vez. Servir ingenuamente longitudes de 1 a 1 000 significa 1 000 planes distintos en lugar de uno, unos 49 MB de caché a 50 KB por plan. Los arreglos que escalan son un parámetro de array, una unión contra una lista VALUES, o una tabla temporal.
Construir una lista IN por concatenación de cadenas es a la vez el vector de inyección clásico y un precipicio de rendimiento. La parametrización arregla lo primero de forma estructural, porque el plan se compila antes de que llegue valor alguno. Lo segundo exige aritmética: los techos de parámetros documentados por los fabricantes, y qué le hace a una caché de planes una consulta cuyo texto cambia con cada longitud de lista.
Por qué la parametrización arregla la inyección de forma estructural
Una base de datos recibe una sentencia como texto y la convierte en un plan por etapas: tokeniza, analiza a un árbol, enlaza nombres a objetos, optimiza y solo entonces ejecuta. Cuando pegas valores dentro del texto, todo eso ocurre después de que tus valores ya se hayan mezclado con tu sintaxis, así que es el analizador el que decide qué significan tus datos. Esa es la vulnerabilidad entera. No va de comillas ni de apóstrofos; va de que la frontera entre código y datos se borra antes de que la base vea siquiera la sentencia.
Una sentencia parametrizada invierte el orden. El texto que envías contiene marcadores y ningún valor, así que la base analiza y planifica una sentencia cuya forma ya es definitiva. Los marcadores son huecos en ese plan terminado, cada uno con un tipo declarado, y enlazar un valor es rellenar un hueco tipado dentro de un objeto compilado — no añadir caracteres a una cadena que se analizará más tarde. No hay análisis posterior. Por eso la garantía es estructural: un valor enlazado no tiene camino alguno para convertirse en un operador, un nombre de tabla, un comentario o una segunda sentencia, porque la etapa que podría haberlo interpretado así terminó antes de que el valor existiera en el mundo de la base.
Escapar intenta lograr el mismo resultado transformando el valor en vez de mover la frontera, y no es una defensa equivalente. Tiene que ser correcto en cada contexto, y los contextos no son iguales: un literal de cadena, un literal numérico, un identificador, el patrón de un LIKE, un valor dentro de una expresión de ruta JSON y un valor dentro de un ORDER BY construido dinámicamente necesitan tratamientos distintos, y algunos no se pueden escapar con seguridad en absoluto. Tiene que ser correcto bajo cada codificación de caracteres, porque la relación entre bytes y caracteres es justo sobre lo que razona una función de escapado. Y tiene que aplicarse en cada punto, para siempre, incluido el que un compañero añada el trimestre que viene con la fecha encima. La parametrización tiene que estar bien una vez, en la forma del código; el escapado tiene que estar bien cada vez, en la disciplina del equipo. Donde el valor de verdad no puede ser un parámetro — un nombre de tabla, un nombre de columna, un sentido de orden — la respuesta no es escaparlo sino validarlo contra una lista blanca fija de los identificadores que tu esquema contiene realmente.
Los techos que cada base documenta realmente
Los números que circulan en las entradas de blog sobre esto están a menudo caducados: léelos en los manuales. El límite de PostgreSQL viene del protocolo de red: el mensaje Bind codifica el número de valores de parámetros en un campo de dieciséis bits, y libpq rechaza cualquier cosa por encima de 65 535 con un mensaje que nombra el intervalo explícitamente. MySQL tiene el mismo techo práctico por la misma razón — el recuento de marcadores en el protocolo cliente-servidor ocupa dos bytes — y superarlo produce el error de servidor 1390, cuyo texto dice que la sentencia preparada contiene demasiados marcadores. El propio rastreador de fallos de MySQL lleva una petición de mejora pendiente para elevar la frontera de 64 k, lo que indica bien que es una restricción real y no teórica.
SQL Server es mucho más estrecho, y es el que pilla por sorpresa a los equipos. Sus especificaciones de capacidad publicadas indican 2 100 parámetros por procedimiento almacenado y 2 100 por función definida por el usuario. Un lote parametrizado desde un controlador cliente se ejecuta mediante sp_executesql, que es un procedimiento almacenado, así que hereda exactamente ese techo — y uno de esos huecos lo consume la maquinaria, de ahí que los profesionales suelan citar 2 099 o 2 098 como el número de valores que pueden enlazar de verdad. Dos mil identificadores no son un lote grande según los estándares actuales: en SQL Server el límite no es un caso extremo teórico, es una restricción de diseño que hay que prever desde el principio.
Oracle limita la propia lista de expresiones en vez del número de parámetros, y la cifra cambió lo bastante recientemente como para que la mayor parte de lo que leerás en línea esté mal. Hasta 19c la SQL Language Reference indica que una lista de expresiones separadas por comas no puede contener más de 1 000 expresiones, y superarlo lanza ORA-01795. Desde 23ai la misma página indica 65 535. Un matiz sobrevive al cambio: una lista de conjuntos de expresiones separados por comas — la forma multicolumna de IN — puede contener cualquier número de conjuntos, pero cada conjunto individual sigue limitado a 1 000 expresiones. Comprueba la versión que ejecutas realmente antes de dimensionar un lote en torno a cualquiera de las dos cifras.
La contaminación de la caché de planes, calculada
Una base cachea planes compilados para no optimizar dos veces la misma sentencia, y la clave de caché se deriva del texto de la sentencia. Una consulta con tres marcadores y una con cuatro son textos distintos, por tanto claves distintas, por tanto entradas distintas — aunque sean la misma consulta con otra longitud de lista. Ese es todo el mecanismo, y la aritmética sale directamente de él: si tu aplicación envía alguna vez longitudes de 1 a N, generas N planes distintos en lugar de uno.
Calcúlalo para un rango realista. Un punto de entrada que recupera pedidos de más de 500 € para un conjunto de identificadores de cliente puede ver desde un identificador hasta unos cientos. Servir ingenuamente longitudes de 1 a 100 produce 100 planes distintos; de 1 a 1 000 produce 1 000; de 1 a 5 000 produce 5 000. Ponle un tamaño plausible a un plan — 50 KB es una cifra media razonable para una consulta con un par de uniones — y la huella de caché ronda los 5 MB con 100 longitudes, 49 MB con 1 000 y 244 MB con 5 000. A 200 KB por plan, cifra que una consulta analítica compleja alcanza sin esfuerzo, 1 000 longitudes cuestan 195 MB y 5 000 cuestan 977 MB. Esa memoria no es gratis: sale del mismo depósito que tu caché de búferes, y en motores donde la caché de planes está acotada expulsa los planes del resto de tu carga, así que el síntoma es que consultas sin relación se vuelven más lentas.
El propio texto de la sentencia también crece, y viaja en cada petición. Una consulta de la forma SELECT ... WHERE id IN con marcadores numerados ronda los 525 bytes con 100 valores, 5 926 bytes con 1 000, 68 927 bytes con 10 000 y 513 207 bytes con 65 535. Medio megabyte de texto SQL por petición, analizado de cero cada vez porque ningún plan en caché volverá a coincidir con él, es la forma del precipicio. Conviene ser preciso sobre los dos costes: el trabajo de análisis y optimización se paga en cada llamada porque la caché siempre falla, y la memoria la consumen entradas que nunca se reutilizan.
Los arreglos que escalan: array, unión VALUES, tabla temporal
El mejor arreglo colapsa toda la familia de sentencias en una. Pasa la lista como un único parámetro de tipo array o tabla y el texto de la sentencia deja de depender de la longitud: un solo texto distinto, un solo plan cacheado, un solo análisis, en cada llamada. PostgreSQL lo ofrece directamente con un parámetro de array comparado mediante ANY, y por eso las aplicaciones PostgreSQL rara vez llegan siquiera al techo de 65 535 — un parámetro de array cuenta como un parámetro, contenga los elementos que contenga. SQL Server tiene parámetros con valores de tabla, que sirven al mismo propósito y son la respuesta estándar a su límite de 2 100. Oracle tiene tipos colección que se pueden consultar como una tabla.
La versión portable de la misma idea es una unión contra una lista VALUES, que todo motor habitual soporta y que el optimizador puede tratar como una relación pequeña en vez de una disyunción larga. Sigue cambiando el texto de la sentencia con el número de filas, así que no arregla la caché de planes por sí sola, pero muy a menudo produce un plan mejor que un OR de mil ramas porque el optimizador puede unirla por hash. Una tabla temporal es la versión que escala sin límite: inserta los identificadores por lotes, une contra la tabla, y el texto de la sentencia es constante haya los identificadores que haya. Cuesta un viaje de ida y vuelta y algo de escritura, así que se gana su sitio por encima de unos pocos miles de valores, no por debajo.
Si de momento estás atrapado con una lista IN literal, el relleno es un parche barato que elimina la mayor parte del daño a la caché. Redondea la lista a un escalón y rellena los huecos sobrantes con un valor que no pueda casar — repetir el primer identificador es el truco correcto más simple, ya que no cambia nada del resultado. Redondear a la siguiente potencia de dos convierte 1 000 longitudes posibles en 11 sentencias distintas, pero desperdicia de media 174 parámetros por llamada en ese rango, un 34,8 % de sobrecoste. Redondear al siguiente múltiplo de diez da 100 sentencias distintas y desperdicia solo 4,5 parámetros de media, un 0,9 %. Ese segundo compromiso suele ser el correcto: cien planes cacheados no son nada, y un uno por ciento de sobrecoste en el recuento de parámetros es invisible.
Lotear por encima del techo
Cuando la lista supera de verdad el techo, divídela. El número de viajes de ida y vuelta sale directamente de los límites: 100 000 identificadores requieren 2 llamadas en PostgreSQL, MySQL u Oracle 23ai, 48 llamadas en SQL Server con 2 100 parámetros, y 100 llamadas en Oracle 19c con 1 000 expresiones. Esa horquilla es la razón de que un tamaño de lote que funciona de maravilla en el contenedor PostgreSQL de un desarrollador se derrumbe en el SQL Server del cliente, y conviene derivar el tamaño de trozo de la base en vez de codificar un número fijo.
Dos detalles importan cuando trozeas. Trocea de forma determinista — ordena los identificadores antes de dividir — para que un reintento envíe los mismos trozos y tus registros sean comparables entre ejecuciones. Y decide explícitamente si la lectura tiene que ser consistente: varias sentencias separadas ven varias instantáneas separadas, así que si los datos pueden cambiar bajo tus pies, envuelve el lote en una sola transacción o acepta que la unión de los resultados no es instantánea de nada. Ese segundo punto produce el fallo que nadie consigue reproducir, porque solo aparece bajo escrituras concurrentes.
El formateo no es cosmético
Dos cosas se siguen del hecho de que la caché de planes se indexe por el texto de la sentencia. La primera: el formateo importa donde lo escribes, no donde lo envías. Una sentencia legible en tu fichero fuente, con las cláusulas en sus propias líneas y la lista repartida en varias, es la diferencia entre una revisión que caza un error y otra que pasa de largo. La segunda: no reformatees el SQL a la salida. Si una envoltura embellece, minifica o normaliza los espacios en tiempo de ejecución, y lo hace de forma inconsistente — pongamos, distinto bajo carga o distinto tras un cambio de configuración — produce entradas de caché nuevas para una sentencia de la que el motor ya tenía plan. Formatea para humanos, en el repositorio; envía una cadena estable.
Hay una razón más para mantener la sentencia legible, y es la razón de que exista este artículo. Una lista IN larga renderizada como una línea enorme lo esconde todo: si los valores están enlazados o interpolados, si el recuento es el que esperabas, si un valor extraño se coló desde otra consulta. Repartida en varias líneas con los marcadores visibles, las tres cosas son obvias de un vistazo, y también el momento en que la lista dejó de ser un puñado de identificadores y se convirtió calladamente en mil.
| Base de datos | Techo | Cómo lo llama el manual | Qué pasa por encima |
|---|---|---|---|
| PostgreSQL | 65 535 parámetros enlazados | El mensaje Bind lleva el recuento de parámetros en un campo de 16 bits | libpq rechaza la llamada antes de enviarla |
| MySQL | 65 535 marcadores | El recuento de marcadores ocupa dos bytes en el protocolo cliente-servidor | Error 1390: la sentencia preparada contiene demasiados marcadores |
| SQL Server | 2 100 parámetros | Parámetros por procedimiento almacenado, y por función definida por el usuario | Un lote parametrizado pasa por sp_executesql y hereda el límite |
| Oracle, hasta 19c | 1 000 expresiones | Una lista de expresiones separadas por comas no puede contener más de 1000 | ORA-01795 |
| Oracle, desde 23ai | 65 535 expresiones | Misma redacción, cifra elevada; cada conjunto de expresiones sigue limitado a 1000 | ORA-01795 |
Preguntas frecuentes
- ¿Escapar es alguna vez un sustituto aceptable del enlace?
- No, y la razón no es que las funciones de escapado estén mal escritas: es que resuelven otro problema. El enlace mueve la frontera entre código y datos de modo que un valor no tenga ruta hacia la sintaxis. Escapar deja la frontera donde está e intenta neutralizar todo valor que pudiera cruzarla, lo que obliga a ser correcto en cada contexto, bajo cada codificación, en cada punto de llamada, para siempre. Los contextos difieren de verdad: un literal de cadena, un literal numérico, un identificador, un patrón de LIKE y un valor dentro de una cláusula ensamblada dinámicamente necesitan cada uno un tratamiento distinto, y algunos no tienen escapado seguro en absoluto. Un solo punto olvidado restaura el agujero entero, y los puntos olvidados son el resultado normal de una base de código con más de un autor. Hay un caso en que un valor de verdad no puede ser un parámetro, porque la base no acepta un marcador ahí: un nombre de tabla, un nombre de columna, un sentido de orden, un LIMIT en algunos motores. La respuesta ahí sigue sin ser escapar. Valida el valor contra una lista blanca fija de los identificadores que tu esquema contiene realmente, y mapea la entrada del usuario a una constante en vez de dejarla pasar.
- ¿Qué hago cuando la lista es más larga de lo que permite mi base?
- Tres opciones, en orden creciente de trabajo y de capacidad de escalar. Primero, un parámetro de array o con valores de tabla, si tu motor lo tiene: la lista entera viaja como un solo parámetro, así que el techo deja de aplicar y el texto de la sentencia se vuelve constante. Es la respuesta correcta en PostgreSQL y en SQL Server, donde el límite de 2 100 muerde pronto. Segundo, una tabla temporal: inserta los identificadores por lotes y luego une contra la tabla. El texto de la sentencia es constante sea cual sea el tamaño de la lista, el optimizador obtiene información real de cardinalidad, y no hay techo alguno — pagas un viaje de ida y vuelta y algo de escritura por ello. Tercero, el troceo: divide la lista, ejecuta la consulta una vez por trozo y fusiona los resultados en tu aplicación. Deriva el tamaño de trozo del límite propio de la base en vez de codificar un número, porque la misma lista requiere 2 viajes en PostgreSQL y 48 en SQL Server. Ordena antes de dividir para que los reintentos sean reproducibles, y mete todo el lote en una transacción si el resultado fusionado tiene que representar un único momento consistente — de lo contrario varias sentencias separadas ven varias instantáneas separadas, que es el origen del fallo intermitente que nadie consigue reproducir.
- ¿Por qué la consulta se vuelve más lenta al crecer la lista, incluso muy por debajo del límite?
- Se acumulan tres efectos, y conviene separarlos porque tienen arreglos distintos. El primero es la compilación: el texto de la sentencia cambia con cada longitud de lista, así que la caché de planes falla siempre y el optimizador vuelve a analizar y planificar desde cero en cada llamada. Con 1 000 valores el texto ya ronda los 5 926 bytes; con 10 000, 68 927. El tiempo de optimización crece con el número de predicados, así que no es un sobrecoste constante. El segundo es la estrategia del optimizador. Una lista IN es lógicamente una cadena de OR, y pasada cierta longitud el optimizador deja de tratarla como un conjunto de búsquedas por índice y considera un recorrido — MySQL lo documenta explícitamente para el optimizador de rangos, que sigue la memoria que consumiría un método de acceso por rango y lo abandona si se fuera a superar un límite configurado, cayendo a un recorrido completo de tabla. Cruzar ese umbral es un precipicio, no una pendiente. El tercero es la presión de caché descrita arriba: tus mil planes de un solo uso expulsan los que usaban otras consultas, así que la ralentización aparece en consultas que no tocaste. Un parámetro de array o una tabla temporal ataca los tres a la vez, porque vuelve constante el texto de la sentencia y le da al optimizador una relación que puede unir.
- ¿Usar un ORM significa que no tengo que pensar en nada de esto?
- Se ocupa de la mitad de seguridad y normalmente no de la mitad operativa. Todos los ORM habituales enlazan valores en vez de interpolarlos, así que el riesgo de inyección desaparece de verdad para los constructores de consultas y las sentencias generadas — con la excepción de cualquier vía de escape a SQL crudo, donde vuelves a escribir los parámetros tú y donde viven de forma abrumadora las vulnerabilidades de las bases de código con ORM. La mitad de rendimiento es otra historia. La mayoría de los ORM renderizan una cláusula WHERE sobre una colección como una lista IN literal con un marcador por elemento, que es exactamente el patrón que cambia el texto de la sentencia con cada longitud e inunda la caché de planes. Muchos generan además tan tranquilos una lista más larga de lo que tu base permite y solo fallan en la ejecución, por eso el techo de 2 100 de SQL Server aparece como incidente de producción y no como fallo de test. Lo que hay que comprobar en tu propia pila es concreto: si tu ORM puede emitir un parámetro de array o con valores de tabla en vez de una lista IN, si trocea automáticamente y con qué tamaño, y si expone el SQL generado en un registro legible. Si no hace nada de eso, una llamada de carga anticipada sobre una colección grande es un problema de caché de planes esperando a un lunes.
- ¿Reformatear mi SQL cambia su rendimiento?
- El plan no, pero posiblemente sí la búsqueda en caché, y esa distinción conviene retenerla. El optimizador trabaja sobre el árbol de análisis, así que los espacios y los saltos de línea no tienen efecto alguno sobre el plan que elige — una sentencia formateada en veinte líneas y la misma minificada en una se ejecutan igual. Lo que no comparten necesariamente es una entrada de caché, porque la caché de planes se indexa por el texto de la sentencia y los motores difieren en cuánto lo normalizan antes. La regla práctica es pues simple: formatea el SQL en tu repositorio, donde lo lee una persona, y envía lo que produzca tu controlador sin un embellecedor ni un minificador en el camino. Una envoltura que reformatea de forma inconsistente — distinto bajo carga o tras alguien cambiar un indicador de configuración — puede crear una segunda entrada de caché para una sentencia de la que el motor ya tenía plan: una regresión pequeña, silenciosa y muy molesta. Formatear en el repositorio tiene un segundo beneficio que importa más: una lista IN larga repartida en líneas hace visible de un vistazo si los valores están enlazados o interpolados, y si el recuento es el que esperabas.
Artículos que podrían interesarte
Todas las guías →Herramientas relacionadas
Fuentes
- PostgreSQL Global Development Group — PostgreSQL documentation — Frontend/Backend Protocol, Message Formats (the Bind message parameter count)
- Oracle / MySQL — MySQL Server Error Reference — error 1390, ER_PS_MANY_PARAM, Prepared statement contains too many placeholders
- Oracle / MySQL — MySQL Reference Manual — Range Optimization and the range_optimizer_max_mem_size system variable
- Microsoft — Maximum Capacity Specifications for SQL Server — Parameters per stored procedure, 2,100
- Oracle — Oracle Database SQL Language Reference 23ai — Expression Lists (65,535 expressions; 1000 per set)
- Oracle — Oracle Database SQL Language Reference 19c — Expression Lists (1000 expressions) and error ORA-01795
- OWASP — SQL Injection Prevention Cheat Sheet — parameterised queries as the primary defence, allow-listing for identifiers
¿Has detectado un error en este artículo?