Curso SQL · Northwind
Tema 15 de 64
Bloque 3 · Transformar valores PostgreSQL 17 northwind

Funciones dentro del SELECT

Este tema es material de consulta, no de lectura lineal. Es el único del curso que agrupa lo que otras guías reparten en cuatro capítulos y veinte lecciones. Léelo una vez para saber qué existe, y vuelve a él cuando necesites algo concreto. Nadie memoriza esta lista; se memoriza dónde está.

1. Qué es

Una función escalar recibe uno o varios valores y devuelve un valor por fila. No colapsa filas —eso lo hacen las agregaciones, funciones-de-agregacion— ni mira otras filas —eso lo hacen las ventanas, que-es-una-funcion-de-ventana.

Se usan sobre todo en el SELECT, pero valen en cualquier sitio donde quepa una expresión: WHERE, ORDER BY, GROUP BY, CHECK.

Cuatro familias:

Familia Para qué
Texto Limpiar, cortar, formatear, buscar dentro
Fecha Extraer partes, truncar, sumar tiempo, formatear
Numéricas Redondear, valor absoluto, potencias
Nulos y condicionales Sustituir ausencias, comparar

2. Texto

Función Qué hace Ejemplo → resultado
upper(t) / lower(t) Mayúsculas / minúsculas upper('chai') → CHAI
initcap(t) Primera letra de cada palabra initcap('chai tea') → Chai Tea
length(t) Número de caracteres length('Chai') → 4
left(t,n) / right(t,n) Primeros / últimos n left('Chocolade',5) → Choco
substring(t FROM p FOR n) Trozo desde la posición p substring('Chocolade' FROM 4 FOR 3) → col
position(sub IN t) Dónde empieza (0 si no está) position('ola' IN 'Chocolade') → 5
trim(t) / ltrim / rtrim Quita espacios trim(' hola ') → hola
btrim(t, chars) Quita los caracteres indicados btrim('xxholaxx','x') → hola
replace(t, de, a) Sustituye un texto replace('Chef Anton''s','Anton','Angel') → Chef Angel's
translate(t, de, a) Sustituye carácter a carácter translate('abc','abc','xyz') → xyz
lpad(t,n,c) / rpad Rellena hasta n lpad('7',3,'0') → 007
repeat(t,n) Repite repeat('ab',3) → ababab
reverse(t) Del revés reverse('chai') → iahc
split_part(t, sep, n) La parte n al partir por sep split_part('Sales Representative',' ',2) → Representative
concat(a,b,...) Concatena ignorando nulos concat('a',NULL,'b') → ab
concat_ws(sep,...) Concatena con separador concat_ws('-','2026','08','20') → 2026-08-20

La diferencia que importa — || contra concat():

SELECT concat('a', NULL, 'b') AS concat_fn, 'a' || NULL || 'b' AS operador;
 concat_fn | operador
-----------+----------
 ab        |

|| se contagia del NULL y anula la cadena entera. concat() lo ignora. Cuando construyas una dirección o un nombre con partes que pueden faltar, usa concat_ws().

substring tiene tres detalles que confunden: - Las posiciones empiezan en 1, no en 0. - substring(t FROM p) sin FOR va hasta el final. - substr(t, p, n) es la forma corta y hace lo mismo.

3. Fecha

Función Qué hace Ejemplo → resultado
current_date / now() Hoy / ahora con zona date / timestamptz
extract(parte FROM f) Un número: año, mes, día, dow… extract(year FROM order_date) → 1996
date_trunc('unidad', f) Corta al principio del periodo date_trunc('month','1997-05-17') → 1997-05-01 00:00:00
to_char(f, formato) Fecha → texto con el formato que quieras to_char(f,'DD/MM/YYYY') → 17/05/1997
f + INTERVAL '1 month' Suma tiempo 1997-06-17
f + 30 Suma días a un date 1997-06-16
f1 - f2 Días entre dos date 671
age(f1, f2) Diferencia legible 1 year 10 mons 2 days

extract acepta year, month, day, hour, quarter, week, doy (día del año) y dow (día de la semana, 0 = domingo).

date_trunc es la función más útil de las cuatro familias. Es la que convierte pedidos sueltos en una serie mensual:

SELECT to_char(order_date, 'YYYY-MM') AS mes,
       count(*)                       AS pedidos,
       round(sum(freight), 2)         AS flete
FROM orders
GROUP BY 1
ORDER BY 1;
   mes   | pedidos |  flete
---------+---------+---------
 1996-07 |      22 | 1288.18
 1996-08 |      25 | 1397.17
 1996-09 |      23 | 1123.48
 1996-10 |      26 | 1520.59
 1996-11 |      25 | 2151.86

Aquí he usado to_char porque produce un texto ordenable (1996-07 < 1996-08). date_trunc es mejor cuando el resultado va a seguir siendo una fecha: mantiene el tipo y permite restar periodos después.

Gotcha: date_trunc() sobre un date devuelve un timestamptz, no un date. Si esperabas una fecha limpia, castea: date_trunc('month', order_date)::date.

El último día del mes, que no tiene función propia:

SELECT date_trunc('month', '1997-05-17'::date) + INTERVAL '1 month - 1 day';
 1997-05-31 00:00:00-05

Formatos de to_char que se usan de verdad:

Formato Da
'YYYY-MM-DD' 1997-05-17
'DD/MM/YYYY' 17/05/1997
'YYYY-MM' 1997-05
'YYYY-"Q"Q' 1997-Q2
'TMMonth' Nombre del mes en el idioma del servidor
'Day' Nombre del día

4. Numéricas

Función Qué hace Ejemplo → resultado
round(n, d) Redondea a d decimales round(21.355, 2) → 21.36
ceil(n) / floor(n) Hacia arriba / abajo ceil(21.35) → 22, floor(21.35) → 21
trunc(n, d) Corta sin redondear trunc(21.99, 1) → 21.9
abs(n) Valor absoluto abs(-5) → 5
mod(a, b) Resto mod(7,2) → 1
power(a, b) Potencia power(2,10) → 1024
sqrt(n) Raíz cuadrada sqrt(16) → 4
greatest(...) / least(...) Mayor / menor de la lista greatest(10,20,5) → 20
to_char(n, formato) Número → texto formateado to_char(1354458.59,'FM999,999,999.00') → 1,354,458.59

round con dos argumentos solo existe para numeric. round(x::double precision, 2) da error de función inexistente. Una razón más para que el dinero sea numeric (tipos-de-datos-en-postgresql).

greatest/least no son agregaciones. Comparan valores de la misma fila, no de filas distintas. El equivalente entre filas es max()/min().

5. Nulos y condicionales

Función Qué hace
coalesce(a, b, c) El primero que no sea NULL
nullif(a, b) NULL si a = b; si no, a
CASE WHEN ... THEN ... END El condicional completo → case-condicionales-en-sql

Están explicadas en null-y-la-logica-de-tres-valores. Se repiten aquí porque es donde las vas a buscar.

El uso que hay que recordar de nullif:

total / nullif(cantidad, 0)     -- NULL en vez de "division by zero"

6. Ejemplos sobre Northwind

Formatear un nombre y calcular antigüedad:

SELECT left(first_name, 1) || '. ' || last_name           AS empleado,
       upper(country)                                     AS pais,
       hire_date,
       extract(year FROM age(current_date, hire_date))::int AS anios
FROM employees
ORDER BY hire_date
LIMIT 4;
   empleado   | pais | hire_date  | anios
--------------+------+------------+-------
 J. Leverling | USA  | 1992-04-01 |    34
 N. Davolio   | USA  | 1992-05-01 |    34
 A. Fuller    | USA  | 1992-08-14 |    34
 M. Peacock   | USA  | 1993-05-03 |    33

34 años de antigüedad porque los datos son de 1992 y hoy es 2026. Es el recordatorio de que Northwind vive en 1996-1998: cualquier cálculo contra current_date da cifras absurdas. Para análisis conviene anclar la fecha "de hoy" al máximo de la base:

SELECT max(order_date) FROM orders;    -- 1998-05-06

Extraer un número que está enterrado en un texto:

SELECT product_name, quantity_per_unit,
       split_part(quantity_per_unit, ' ', 1) AS cantidad
FROM products LIMIT 5;
         product_name         |  quantity_per_unit  | cantidad
------------------------------+---------------------+----------
 Chai                         | 10 boxes x 30 bags  | 10
 Chang                        | 24 - 12 oz bottles  | 24
 Aniseed Syrup                | 12 - 550 ml bottles | 12

Funciona para estas cinco filas. quantity_per_unit es texto libre con al menos tres formatos distintos, y split_part solo acierta cuando el número va primero y separado por un espacio. Esta consulta es un ejemplo de función, no una solución: extraer datos de un campo de texto libre siempre es frágil, y la respuesta profesional es pedir que ese dato sea una columna numérica.

Limpiar un teléfono:

SELECT company_name, phone,
       replace(replace(phone, '(', ''), ')', '') AS limpio
FROM customers WHERE phone LIKE '(%' LIMIT 3;
            company_name            |     phone      |    limpio
------------------------------------+----------------+--------------
 Ana Trujillo Emparedados y helados | (5) 555-4729   | 5 555-4729
 Antonio Moreno Taquería            | (5) 555-3932   | 5 555-3932
 Around the Horn                    | (171) 555-7788 | 171 555-7788

Dos replace anidados para dos caracteres. Con más de dos, translate lo hace de una vez: translate(phone, '()- ', '') los quita todos.

7. Errores comunes

  1. || con una parte nula. Anula la cadena. Usa concat_ws().
  2. Creer que substring empieza en 0. Empieza en 1.
  3. date_trunc devolviendo timestamptz cuando esperabas date. Castea.
  4. round(x, 2) sobre un double precision. No existe esa función; hay que castear a numeric.
  5. Aplicar una función a una columna dentro del WHERE. WHERE extract(year FROM order_date) = 1997 impide usar un índice sobre order_date. La versión que sí lo usa es WHERE order_date >= '1997-01-01' AND order_date < '1998-01-01'. Ver indices.
  6. to_char con 'Month' esperando español. Da el idioma del servidor. Con TM delante respeta la configuración regional; sin ella es inglés.
  7. Extraer datos de texto libre y darlo por bueno. Ver quantity_per_unit.

8. Diferencias con SQL Server

Esta es la sección que hay que llevarse a una entrevista. Es la tabla de traducción completa.

8.1 Texto

Necesidad SQL Server PostgreSQL
Longitud LEN(t) length(t)
Buscar dentro CHARINDEX(sub, t) position(sub IN t) o strpos(t, sub)
Buscar con patrón PATINDEX('%x%', t) t ~ 'x' (regex)
Trozo SUBSTRING(t, p, n) substring(t FROM p FOR n)
Concatenar + o CONCAT || o concat()
Insertar/reemplazar por posición STUFF(...) ❌ No existe → overlay(t PLACING s FROM p FOR n)
Partir por separador STRING_SPLIT(t, sep) string_to_array(t, sep) o split_part(t, sep, n)
Juntar filas en texto STRING_AGG(col, sep) string_agg(col, sep) — igual
Repetir REPLICATE(t, n) repeat(t, n)

8.2 Fecha

Necesidad SQL Server PostgreSQL
Ahora GETDATE() now() / current_date
Extraer parte DATEPART(year, f) extract(year FROM f)
Nombre de la parte DATENAME(month, f) to_char(f, 'TMMonth')
Sumar tiempo DATEADD(month, 1, f) f + INTERVAL '1 month'
Diferencia DATEDIFF(day, f1, f2) f2 - f1 (para date) o age(f2, f1)
Fin de mes EOMONTH(f) ❌ → date_trunc('month',f) + INTERVAL '1 month - 1 day'
Truncar DATETRUNC(month, f) (2022+) date_trunc('month', f)

8.3 Números y conversión

Necesidad SQL Server PostgreSQL
Convertir CAST(x AS t) / CONVERT(t, x) x::t / CAST(x AS t)
Formatear fecha CONVERT(varchar, f, 103) to_char(f, 'DD/MM/YYYY')
Primer no nulo ISNULL(a,b) o COALESCE coalesce(a,b)
Redondear ROUND(n, d) round(n, d) — solo numeric

Los dos que más duelen al migrar son DATEDIFF y CONVERT con estilos numéricos. DATEDIFF(month, a, b) cuenta fronteras de mes cruzadas, no meses completos: no hay una traducción de una línea, hay que decidir qué querías contar. Y los estilos numéricos de CONVERT (103, 112, 120…) son códigos que solo existen en SQL Server; se traducen a un formato de to_char, uno por uno.

9. Ejercicios

  1. Devuelve el nombre de cada producto en mayúsculas, su longitud, y sus 10 primeros caracteres.
  2. Los pedidos agrupados por trimestre (1996-Q3, 1996-Q4…) con su número de pedidos.
  3. La dirección completa de cada cliente en una sola columna: calle, ciudad, región y país separados por coma, sin que desaparezca la fila cuando falta la región.
  4. Los empleados con su edad en años en el momento de su contratación.
  5. El precio de cada producto formateado como texto con separador de miles y dos decimales, y el descuento medio de order_details como porcentaje con un decimal.

10. Pregunta de negocio

Marketing quiere un archivo con "una línea por cliente, lista para pegar en la herramienta de envíos": nombre de contacto, empresa, y dirección postal completa en un solo campo. Insisten en que no puede faltar ninguno de los 91 clientes.

La última frase es la trampa. Y ya la has visto antes en este tema.

? RespuestaRespóndela antes de abrir
SELECT contact_name,
       company_name,
       concat_ws(', ',
                 nullif(trim(address), ''),
                 nullif(trim(postal_code), ''),
                 nullif(trim(city), ''),
                 nullif(trim(region), ''),
                 nullif(trim(country), '')) AS direccion
FROM customers
ORDER BY company_name;

Devuelve los 91 clientes, y esa es la parte que había que acertar:

  1. concat_ws en vez de ||. Con ||, los 60 clientes sin región habrían salido con la dirección entera en NULL — no con la región vacía, con todo vacío. Marketing habría recibido 91 filas y 60 sin dirección, y probablemente no lo habría mirado.
  2. nullif(trim(x), '') en cada parte. concat_ws salta los NULL, pero no salta las cadenas vacías: un city = '' produciría una coma suelta. Convertir el vacío en NULL primero cierra ese agujero.
  3. El código postal antes de la ciudad, que es el orden postal real en Europa. Detalle tonto que decide si la herramienta de envíos parsea bien o no.

Y lo que hay que decir al entregarlo: un cliente tiene el código postal en NULL y 60 no tienen región. Las 91 filas salen completas en el sentido de que no falta ninguna, pero 61 direcciones están incompletas en origen. Eso no lo arregla ninguna función: es un problema del dato, y hay que decirlo antes de que vuelvan 61 cartas.

✓ SolucionesResuélvelos antes de abrir

1.

SELECT upper(product_name)  AS nombre,
       length(product_name) AS largo,
       left(product_name, 10) AS corto
FROM products
ORDER BY largo DESC
LIMIT 3;
              nombre              | largo |   corto
----------------------------------+-------+------------
 LOUISIANA FIERY HOT PEPPER SAUCE |    32 | Louisiana
 JACK'S NEW ENGLAND CLAM CHOWDER  |    31 | Jack's New
 UNCLE BOB'S ORGANIC DRIED PEARS  |    31 | Uncle Bob'

Y un caso que merece mirarse aparte, porque enseña que upper() no es tan simple como parece:

SELECT product_name, upper(product_name) FROM products WHERE product_name LIKE '%Soße%';
          product_name           |               upper
---------------------------------+---------------------------------
 Original Frankfurter grüne Soße | ORIGINAL FRANKFURTER GRÜNE SOßE

La ü sube a Ü, pero la ß se queda en minúscula: su mayúscula tradicional son dos caracteres (SS) y PostgreSQL no cambia la longitud del texto. upper() no es reversible en todos los idiomas, y comparar upper(a) = upper(b) no es un sustituto fiable de ILIKE.

2.

SELECT to_char(order_date, 'YYYY-"Q"Q') AS trimestre, count(*) AS pedidos
FROM orders
GROUP BY 1
ORDER BY 1;
 trimestre | pedidos
-----------+---------
 1996-Q3   |      70
 1996-Q4   |      82
 1997-Q1   |      92
 1997-Q2   |      93
 1997-Q3   |     103
 1997-Q4   |     120
 1998-Q1   |     182
 1998-Q2   |      88

El negocio crece trimestre a trimestre hasta 1998-Q1. 1998-Q2 no cae: está incompleto — la base termina el 6 de mayo. Es el error más común al graficar una serie: el último periodo siempre parece un desplome.

3.

SELECT company_name,
       concat_ws(', ', address, city, region, country) AS direccion
FROM customers
LIMIT 3;
            company_name            |                     direccion
------------------------------------+----------------------------------------------------
 Alfreds Futterkiste                | Obere Str. 57, Berlin, Germany
 Ana Trujillo Emparedados y helados | Avda. de la Constitución 2222, México D.F., Mexico
 Antonio Moreno Taquería            | Mataderos  2312, México D.F., Mexico

concat_ws salta los nulos y no deja doble coma. Con || habrían salido 60 direcciones vacías.

4.

SELECT first_name || ' ' || last_name AS empleado,
       birth_date, hire_date,
       extract(year FROM age(hire_date, birth_date))::int AS edad_al_entrar
FROM employees
ORDER BY edad_al_entrar;
    empleado     | birth_date | hire_date  | edad_al_entrar
-----------------+------------+------------+----------------
 Anne Dodsworth  | 1966-01-27 | 1994-11-15 |             28
 Janet Leverling | 1963-08-30 | 1992-04-01 |             28
 Michael Suyama  | 1963-07-02 | 1993-10-17 |             30

Aquí age() está bien usada: compara dos fechas de la base, no contra current_date. Ese es el patrón correcto con datos históricos.

5.

SELECT product_name, to_char(unit_price, 'FM999,990.00') AS precio
FROM products ORDER BY unit_price DESC LIMIT 2;
      product_name       |  precio
-------------------------+----------
 Côte de Blaye           | 263.50
 Thüringer Rostbratwurst | 123.79
SELECT to_char(avg(discount) * 100, 'FM990.0') || '%' AS descuento_medio FROM order_details;
 descuento_medio
-----------------
 5.6%

El FM quita los espacios de relleno que to_char añade por defecto; sin él el resultado sale alineado con espacios delante. Y el 0 en 990.00 fuerza a que se muestre el cero: con 999.99, un precio de 0.50 saldría como .50.