Ir al contenido
OneKitly

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

Daniel OkonkwoDesarrollador front-end y redactor de Tecnología en OneKitly

Rendimiento web · Formatos de archivo

Verificado con 7 fuentes

Ver perfil
En resumen

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.

Techos documentados, leídos en el manual de cada fabricante en agosto de 2026
Base de datosTechoCómo lo llama el manualQué pasa por encima
PostgreSQL65 535 parámetros enlazadosEl mensaje Bind lleva el recuento de parámetros en un campo de 16 bitslibpq rechaza la llamada antes de enviarla
MySQL65 535 marcadoresEl recuento de marcadores ocupa dos bytes en el protocolo cliente-servidorError 1390: la sentencia preparada contiene demasiados marcadores
SQL Server2 100 parámetrosParámetros por procedimiento almacenado, y por función definida por el usuarioUn lote parametrizado pasa por sp_executesql y hereda el límite
Oracle, hasta 19c1 000 expresionesUna lista de expresiones separadas por comas no puede contener más de 1000ORA-01795
Oracle, desde 23ai65 535 expresionesMisma redacción, cifra elevada; cada conjunto de expresiones sigue limitado a 1000ORA-01795
Generador de cláusula SQL INConvierte una lista de valores (uno por línea) en una cláusula SQL IN (...).Probar la herramienta

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
ExplicaciónJSON es más simple de lo que crees, y ese es el problemaJSON no tiene tipo entero, ni tipo fecha, ni comentarios, ni esquema. Cada una de esas ausencias produce un fallo concreto: un identificador de 19 dígitos vuelve desviado en 21, una marca de tiempo se convierte en una cadena que nadie acordó, NaN no se puede escribir y las claves duplicadas son legales. Todo ejecutado, en dos lenguajes.ExplicaciónDe JSON a CSV cuando la estructura está anidada: por qué no hay una respuesta correctaLos mismos dos pedidos salen con cinco columnas de un conversor y diez de otro, y ninguno se equivoca. Rutas con puntos, arrays de escalares, arrays de objetos y registros con claves distintas: cuatro decisiones, tomadas por ti y casi siempre en silencio.ExplicaciónPunto y coma, tabulación, barra: elegir un delimitador que sobreviva al viajePor qué el idioma de quien lee decide el delimitador, qué hace el conversor con el entrecomillado al cambiar, qué es realmente la primera línea sep=, y el recuento de celdas entrecomilladas en la misma exportación escrita de cinco maneras.GuíaCódigos de estado HTTP explicados: los que de verdad se confunden301 frente a 308, 302 frente a 307, 401 frente a 403, 404 frente a 410 — y qué promete de verdad Retry-After en un 429 o un 503. Los pares donde elegir mal el código cambia el comportamiento, no solo la redacción.TutorialBases de regex: guía para principiantesUna expresión regular es un patrón para buscar texto. Aquí tienes los bloques — clases de caracteres, cuantificadores y anclas — con un ejemplo.ExplicaciónExtraer todas las direcciones de correo o URL de un bloque de textoUna URL al final de una frase se queda con el punto; una dirección de correo al final de esa misma frase, no. Un nombre acentuado dentro de una dirección vuelve truncado. Cada caso se pasó por las herramientas y aquí está la salida exacta.

Herramientas relacionadas

Fuentes

¿Has detectado un error en este artículo?