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 undatedevuelve untimestamptz, no undate. 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
||con una parte nula. Anula la cadena. Usaconcat_ws().- Creer que
substringempieza en 0. Empieza en 1. date_truncdevolviendotimestamptzcuando esperabasdate. Castea.round(x, 2)sobre undouble precision. No existe esa función; hay que castear anumeric.- Aplicar una función a una columna dentro del
WHERE.WHERE extract(year FROM order_date) = 1997impide usar un índice sobreorder_date. La versión que sí lo usa esWHERE order_date >= '1997-01-01' AND order_date < '1998-01-01'. Ver indices. to_charcon'Month'esperando español. Da el idioma del servidor. ConTMdelante respeta la configuración regional; sin ella es inglés.- 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
- Devuelve el nombre de cada producto en mayúsculas, su longitud, y sus 10 primeros caracteres.
- Los pedidos agrupados por trimestre (
1996-Q3,1996-Q4…) con su número de pedidos. - 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.
- Los empleados con su edad en años en el momento de su contratación.
- El precio de cada producto formateado como texto con separador de miles y dos decimales, y el descuento medio de
order_detailscomo 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.