Si una query se puede resolver con SQL estándar, empezamos por ahí, por tres razones bastante terrenales: deja menos cosas atadas al motor, la puede leer más gente y, el día que haya que mover esa lógica a otro lado, el trabajo es adaptar en vez de reescribir. Ni pureza ni una promesa de velocidad.

Las excepciones existen y son legítimas; van al final. Antes, un ejemplo que se repite mucho más de lo que uno querría.

Un stored procedure de los de siempre

En muchas empresas hay lógica de negocio (comisiones, saldos, cierres) que vive en procedimientos almacenados y recorre los registros de a uno. Un cursor toma un agente, consulta sus ventas, decide un multiplicador según su categoría, inserta el resultado y pasa al siguiente. Funciona desde hace años, y por eso mismo nadie lo quiere tocar.

Recortado a lo esencial, en T-SQL:

-- T-SQL: un agente por vez
OPEN cur_agents;
FETCH NEXT FROM cur_agents INTO @agent_id, @tier, @rate;
WHILE @@FETCH_STATUS = 0
BEGIN
    IF @tier = 'GOLD'        SET @mult = 1.25;
    ELSE IF @tier = 'SILVER' SET @mult = 1.10;
    ELSE                     SET @mult = 1.00;

    SELECT @sales = ISNULL(SUM(net_amount), 0)
    FROM sales WHERE agent_id = @agent_id;

    INSERT INTO commissions (agent_id, amount)
    VALUES (@agent_id, @sales * @rate * @mult);

    FETCH NEXT FROM cur_agents INTO @agent_id, @tier, @rate;
END

No hay nada raro ahí; quien trabaja seguido con SQL Server lo lee sin problema. Lo que sí hay es una decisión tomada por adelantado: nosotros definimos cómo se recorren los datos, cuándo se consulta, cuándo se inserta y en qué orden.

La misma lógica, escrita de forma declarativa:

-- ANSI: todos los agentes en una pasada
with tiers as (
    select agent_id, base_rate,
           case tier when 'GOLD'   then 1.25
                     when 'SILVER' then 1.10
                     else               1.00
           end as mult
    from agents
    where status = 'ACTIVE'
),
agent_sales as (
    select agent_id, sum(net_amount) as total
    from sales
    group by agent_id
)
insert into commissions (agent_id, amount)
select t.agent_id,
       coalesce(s.total, 0) * t.base_rate * t.mult
from tiers t
left join agent_sales s on s.agent_id = t.agent_id;

El cursor pasó a ser un join, el IF/ELSE un CASE, y en vez de una operación por agente describimos el resultado que queremos sobre el conjunto completo, sin que la regla de negocio cambie en nada.

Lo que gana el optimizador

Con el cursor, cada agente dispara una agregación sobre sales y un insert. Diez mil agentes son diez mil veces ese patrón. En la versión declarativa el motor agrega las ventas una vez, resuelve el join y elige el plan que le parezca mejor, que es para lo que existe.

Hay motores, volúmenes y casos donde una query set-based no le gana a un procedimiento bien hecho, así que esto no es una ley. Lo que sí sostenemos es que preferimos entregarle al optimizador el problema entero antes que convertir nosotros la ejecución en un loop.

Cuánta gente puede leerla

Este beneficio suele quedar tapado por la discusión de performance, y para nosotros pesa más.

Una query hecha de joins, agregaciones, CASE y CTEs con nombre la puede seguir casi cualquiera que trabaje con datos: la abre un analista, la corre una herramienta de BI, la revisa alguien de otro equipo. Cuando empiezan a aparecer cursores, variables, tablas temporales y control de flujo, el grupo de personas que puede modificar esa lógica con confianza se achica, y a la larga eso se paga.

Y cuando hay que moverla

Mientras todo vive en el mismo motor, la portabilidad parece una preocupación teórica. Deja de serlo con una migración de SQL Server a otro warehouse, con una transición gradual entre plataformas, o con algo mucho más cotidiano: querer correr parte de la lógica de producción en un DuckDB local para desarrollar y probar.

Si el grueso de las transformaciones usa SQL relativamente estándar, aparecen diferencias puntuales y se resuelven. Si buena parte de la lógica está adentro de procedimientos del motor, ya no estás adaptando queries: estás reimplementando comportamiento. Ahí empieza una tarea que conocemos bien: abrir un procedimiento de hace cinco años e intentar descubrir si ese ELSE 1.00 es una decisión de negocio, un fallback defensivo, o algo que quedó así porque siempre estuvo así.

ANSI puro tampoco existe

Hablar de “SQL portable” tiene un límite. Apenas una query hace algo mínimamente interesante aparecen diferencias entre motores: fechas, arrays, JSON, casts, generación de series, funciones analíticas, paginación. LIMIT, TOP y FETCH FIRST son el ejemplo obvio, pero lejos de ser el único.

Por eso no intentamos escribir SQL que se copie sin cambios entre cinco motores; en la práctica no es realista. El objetivo es más modesto: que la mayor parte posible de la lógica de negocio viva en el lenguaje común, y que las partes que dependen de una tecnología queden explícitas y fáciles de encontrar. Lo que pertenece al motor por naturaleza (particiones, índices, distribución, vistas materializadas) no tiene sentido abstraerlo. La regla aplica sobre la lógica de las queries.

Cuándo sí usamos el dialecto del motor

Cuando aporta algo que el estándar no puede dar, y el caso lo exige de verdad.

En un trabajo reciente con ClickHouse usamos varias construcciones propias del motor porque resolvían problemas concretos mucho mejor que las alternativas genéricas: LIMIT 1 BY para deduplicar en el stream sin perder la lectura ordenada del índice, FINAL donde hacía falta leer estado consolidado, vistas materializadas refrescables para mantener capas. Nada de eso es ANSI, y tampoco intentamos que lo fuera. La diferencia con un uso caprichoso es que en cada caso podíamos señalar exactamente qué ganábamos, con números medidos sobre nuestros datos.

Antes de sumar una excepción nos hacemos algunas preguntas:

  • ¿Tenemos un problema real que resolver, o una feature que nos gusta?
  • ¿Medimos la diferencia con nuestros datos, o la vimos en un benchmark ajeno?
  • ¿La mejora justifica meter una dependencia del motor?
  • ¿Podemos dejar esa dependencia en pocos lugares, y anotada?
  • ¿Va a ser evidente para otra persona por qué se tomó esa decisión?

No usamos un porcentaje fijo como frontera. Un 15 % es irrelevante en un batch nocturno y decisivo en una query que corre miles de veces por minuto. Lo que pedimos es que la decisión salga del caso real, y que la ganancia sea lo bastante grande como para que nadie tenga que discutirla.

Hay una señal que nos preocupa: una feature específica que empieza resolviendo un caso donde hace diferencia y a los meses aparece en todas las queries. Ahí ya no estás aprovechando el motor de forma selectiva; estás construyendo la capa de lógica alrededor de su dialecto. Puede ser una decisión válida (si esa plataforma va a ser el centro de la arquitectura por años, quizás convenga aceptar más lock-in), pero conviene que sea consciente y no algo que se descubre después.

Qué hacemos con lo que ya existe

No reescribiríamos un stored procedure solo para poder decir que ahora usamos SQL más estándar. Si funciona y no genera problemas, hay cosas más importantes que hacer.

Cuando sí decidimos convertirlo, la condición es simple y no se negocia: la versión nueva tiene que producir el mismo resultado. Corremos las dos implementaciones sobre los mismos datos y comparamos cantidad de filas, claves presentes y ausentes, las agregaciones que importan, nulos, diferencias numéricas y los casos límite que ya conocemos. Los procedimientos viejos acumulan comportamientos que no están documentados en ninguna parte; reescribirlos sin esta comparación es la forma más fácil de cambiar una regla de negocio sin darse cuenta.

Y no empezaríamos por el procedimiento más monstruoso del sistema. Conviene buscar primero uno que cambie seguido, que lea mucha gente, o que esté trabando otra cosa: ahí el beneficio se ve enseguida.

Una regla aburrida a propósito

Usamos SQL estándar como punto de partida porque nos parece un default razonable: las queries se mueven, se revisan y se entienden con menos esfuerzo, y cada transformación deja de ser una decisión de infraestructura. Después aprovechamos el motor cuando hay una razón concreta.

Lo que buscamos es que, cuando dentro de dos años alguien abra una de estas queries, pueda entender qué hace sin tener que aprender antes la historia completa de la plataforma donde fue escrita.

Ver todos los artículos