De todas las preguntas que se hace la dirección de TiendaVerde, la mayoría llevan una fecha dentro: ¿cuánto facturamos este mes?, ¿qué clientes no compran desde hace medio año?, ¿cuántos días tardó en llegar esa devolución?, ¿crecemos trimestre a trimestre? Hasta ahora solo sabías comparar fechas con >= y <. Esta lección convierte la columna fecha_pedido en una herramienta de análisis.
También salda la deuda de 04-05, donde EXTRACT apareció de pasada para agrupar por año y se dejó explicado "para el módulo 6". Y trae la pieza que faltaba para los informes: DATE_TRUNC, la función que convierte 20 pedidos sueltos en una serie mensual.
Nota sobre la reproducibilidad: los ejemplos que necesitan "hoy" usan la fecha fija DATE '2026-03-01' en lugar de CURRENT_DATE, para que los resultados que ves aquí coincidan con los tuyos. En producción usarías CURRENT_DATE.
Contenido
- Los tipos temporales, y por qué TiendaVerde usa
DATE - El "ahora":
CURRENT_DATE,NOW()yCLOCK_TIMESTAMP() - Aritmética de fechas,
INTERVALyAGE() EXTRACTyDATE_PARTDATE_TRUNC: el patrón del informe temporal- Formatear con
TO_CHAR, analizar conTO_DATE - Informes sin huecos con
generate_series - Tabla comparativa por motor
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Los tipos temporales, y por qué TiendaVerde usa
DATE
DATE| Tipo | Qué guarda | Ejemplo de literal | Tamaño |
|---|---|---|---|
DATE |
Una fecha de calendario, sin hora | DATE '2025-03-04' |
4 bytes |
TIME |
Una hora del día, sin fecha | TIME '18:30:00' |
8 bytes |
TIMESTAMP |
Fecha + hora, sin zona horaria | TIMESTAMP '2025-03-04 18:30:00' |
8 bytes |
TIMESTAMPTZ |
Fecha + hora, con zona horaria | TIMESTAMPTZ '2025-03-04 18:30:00+01' |
8 bytes |
INTERVAL |
Una duración, no un instante | INTERVAL '30 days' |
16 bytes |
TiendaVerde usa DATE en todas sus columnas temporales —fecha_pedido, fecha_registro, fecha_alta, fecha_contratacion, resenas.fecha, devoluciones.fecha— porque en este modelo son hechos de calendario: un pedido es "del 4 de marzo", no "de las 18:47:03 del 4 de marzo". Guardar una hora que nadie va a usar añade ambigüedad sin aportar información.
Qué cambiaría con marcas de tiempo
En una tienda real querrías la hora: para medir el tiempo de preparación, para saber a qué hora se compra más, para auditar quién cambió qué y cuándo. Y en cuanto añades la hora, aparece la zona horaria:
| Situación | Con DATE |
Con TIMESTAMP |
Con TIMESTAMPTZ |
|---|---|---|---|
| Pedido a las 23:50 en Lyon (CET) | "4 de marzo", sin más | 2025-03-04 23:50 — ¿hora de dónde? |
Instante inequívoco; se muestra en la zona de cada usuario |
| Comparar con un pedido de Lisboa (WET) | Directo | Incomparable: una hora de diferencia invisible | Correcto: el motor normaliza |
| Cambio de hora de octubre | No existe el problema | Dos instantes con la misma representación | Resuelto |
fecha >= '2025-03-01' AND fecha < '2025-04-01' |
Exacto | Exacto | Depende de la zona de sesión |
La recomendación profesional: en una aplicación real,
TIMESTAMPTZes casi siempre la elección correcta.TIMESTAMPsin zona parece más simple y es una trampa: guarda un número sin decir a qué reloj pertenece, y el día que tengas usuarios en dos países ya no hay forma de saberlo.TIMESTAMPTZguarda internamente un instante en UTC y lo convierte a la zona de cada sesión al leerlo. YDATEes correcto solo cuando el dato es de verdad una fecha de calendario: una fecha de nacimiento, un día de facturación, un vencimiento.
Ese es exactamente el criterio por el que TiendaVerde usa DATE, y por el que tu próxima aplicación probablemente no deba hacerlo.
- El "ahora":
CURRENT_DATE, NOW() y CLOCK_TIMESTAMP()
CURRENT_DATE, NOW() y CLOCK_TIMESTAMP()| Función | Qué devuelve | Cuándo se evalúa |
|---|---|---|
CURRENT_DATE / CURRENT_TIME |
DATE de hoy / TIME WITH TIME ZONE |
Inicio de la transacción |
CURRENT_TIMESTAMP |
TIMESTAMPTZ |
Inicio de la transacción |
NOW() |
Idéntica a CURRENT_TIMESTAMP |
Inicio de la transacción |
STATEMENT_TIMESTAMP() |
TIMESTAMPTZ |
Inicio de la sentencia |
CLOCK_TIMESTAMP() |
TIMESTAMPTZ |
En el instante de la llamada |
La diferencia entre NOW() y CLOCK_TIMESTAMP() sorprende la primera vez. Si abres BEGIN;, ejecutas SELECT NOW(), CLOCK_TIMESTAMP();, esperas unos segundos y repites la consulta antes del COMMIT, la primera columna devuelve exactamente el mismo valor las dos veces y la segunda no. NOW() está congelada durante toda la transacción, y eso es una garantía deliberada: si un proceso inserta cien filas con NOW(), las cien comparten la misma marca y forman un lote identificable. Para medir cuánto tarda algo dentro de una transacción, NOW() no sirve: necesitas CLOCK_TIMESTAMP().
Consecuencia práctica:
fecha_registro DATE NOT NULL DEFAULT CURRENT_DATE(elDEFAULTdeclientesen 01-06) es estable y correcto. UnDEFAULT clock_timestamp()sería volátil y, como viste en 05-06, obligaría a reescribir la tabla entera al añadir la columna.
- Aritmética de fechas,
INTERVAL y AGE()
INTERVAL y AGE()| Operación | Tipo del resultado | Ejemplo | Resultado |
|---|---|---|---|
date - date |
INTEGER (días) |
DATE '2026-02-21' - DATE '2025-03-04' |
354 |
date + entero |
DATE |
DATE '2025-03-04' + 30 |
2025-04-03 |
date + INTERVAL |
TIMESTAMP |
DATE '2025-03-04' + INTERVAL '30 days' |
2025-04-03 00:00:00 |
timestamp - timestamp |
INTERVAL |
— | 1 day 02:15:00 |
AGE(a, b) |
INTERVAL legible |
AGE(DATE '2026-03-01', DATE '2025-01-10') |
1 year 1 mon 19 days |
AGE(x) |
INTERVAL desde hoy |
AGE(fecha_registro) |
equivale a AGE(CURRENT_DATE, fecha_registro) |
Dos trampas en esa tabla. date - date da un entero y timestamp - timestamp da un intervalo: el mismo signo - con dos semánticas, de modo que migrar una columna de DATE a TIMESTAMP cambia el tipo de todas tus restas en silencio. Y date + INTERVAL devuelve un TIMESTAMP, no un DATE; si necesitas una fecha, añade ::DATE.
INTERVAL y sus unidades
SELECT DATE '2025-01-31' + INTERVAL '1 month' AS fin_de_enero,
DATE '2025-03-04' + INTERVAL '2 weeks' AS dos_semanas,
DATE '2026-02-21' - INTERVAL '6 months' AS hace_medio_ano;| fin_de_enero | dos_semanas | hace_medio_ano |
|---|---|---|
| 2025-02-28 00:00:00 | 2025-03-18 00:00:00 | 2025-08-21 00:00:00 |
Fíjate en la primera: 31 de enero + 1 mes = 28 de febrero, porque el 31 de febrero no existe y PostgreSQL ajusta al último día del mes. Un mes no es una cantidad fija de días: INTERVAL '1 month' e INTERVAL '30 days' son cosas distintas.
AGE(): la antigüedad legible
AGE no devuelve días: devuelve años, meses y días, como diría una persona.
SELECT id, CONCAT_WS(' ', nombre, apellidos) AS cliente, fecha_registro,
DATE '2026-03-01' - fecha_registro AS dias,
AGE(DATE '2026-03-01', fecha_registro) AS antiguedad
FROM clientes WHERE id IN (1, 4, 7, 12, 15) ORDER BY id;| id | cliente | fecha_registro | dias | antiguedad |
|---|---|---|---|---|
| 1 | Lucía Martínez Soler | 2025-01-10 | 415 | 1 year 1 mon 19 days |
| 4 | Javier Ortega Ruiz | 2025-02-14 | 380 | 1 year 15 days |
| 7 | Sofia Moreira Costa | 2025-03-21 | 345 | 11 mons 8 days |
| 12 | Diego Ramos Herrera | 2025-06-01 | 273 | 9 mons |
| 15 | Inés Carrasco Vega | 2026-01-08 | 52 | 1 mon 21 days |
Dos detalles: PostgreSQL omite los componentes que valen cero (Javier no muestra "0 mons"; Diego, ni meses ni días), y AGE es lo que quieres para mostrar a un humano mientras que la resta en días es lo que quieres para ordenar y comparar.
Caso: días entre el pedido y la devolución
SELECT d.id AS devolucion, d.pedido_id, pe.fecha_pedido,
d.fecha AS fecha_devolucion,
d.fecha - pe.fecha_pedido AS dias_transcurridos,
d.importe
FROM devoluciones AS d
JOIN pedidos AS pe ON pe.id = d.pedido_id
ORDER BY d.id;| devolucion | pedido_id | fecha_pedido | fecha_devolucion | dias_transcurridos | importe |
|---|---|---|---|---|---|
| 1 | 6 | 2025-05-23 | 2025-05-25 | 2 | 26.75 |
| 2 | 10 | 2025-08-03 | 2025-08-11 | 8 | 34.02 |
| 3 | 13 | 2025-10-22 | 2025-10-30 | 8 | 19.80 |
Las tres devoluciones de TiendaVerde: la del pedido cancelado se tramitó en 2 días y las dos por incidencia de producto, en 8. Con TIMESTAMP en lugar de DATE esta resta devolvería un INTERVAL como 8 days 03:12:00, más preciso y menos cómodo de agregar.
EXTRACT y DATE_PART
EXTRACT y DATE_PARTEXTRACT(campo FROM fecha) saca un componente. DATE_PART('campo', fecha) hace lo mismo con sintaxis de función; EXTRACT es el estándar SQL y es la forma preferible.
| Campo | Qué devuelve | Para DATE '2025-03-04' |
|---|---|---|
YEAR |
Año | 2025 |
MONTH |
Mes, 1-12 | 3 |
DAY |
Día del mes | 4 |
QUARTER |
Trimestre, 1-4 | 1 |
WEEK |
Semana ISO, 1-53 | 10 |
DOW / ISODOW |
Día de la semana: 0 = domingo / 1 = lunes | 2 / 2 |
DOY |
Día del año, 1-366 | 63 |
EPOCH |
Segundos desde 1970-01-01 | 1741046400 |
SELECT id, fecha_pedido,
EXTRACT(YEAR FROM fecha_pedido) AS anio,
EXTRACT(MONTH FROM fecha_pedido) AS mes,
EXTRACT(QUARTER FROM fecha_pedido) AS trimestre,
EXTRACT(DOW FROM fecha_pedido) AS dow,
EXTRACT(WEEK FROM fecha_pedido) AS semana_iso
FROM pedidos WHERE id IN (1, 8, 13, 17, 20) ORDER BY id;| id | fecha_pedido | anio | mes | trimestre | dow | semana_iso |
|---|---|---|---|---|---|---|
| 1 | 2025-03-04 | 2025 | 3 | 1 | 2 | 10 |
| 8 | 2025-06-28 | 2025 | 6 | 2 | 6 | 26 |
| 13 | 2025-10-22 | 2025 | 10 | 4 | 3 | 43 |
| 17 | 2026-01-13 | 2026 | 1 | 1 | 2 | 3 |
| 20 | 2026-02-21 | 2026 | 2 | 1 | 6 | 8 |
Tres avisos: DOW empieza en domingo con el 0 (convención de C, no europea); WEEK es la semana ISO, así que los primeros días de enero pueden pertenecer a la semana 52 o 53 del año anterior; y EXTRACT devuelve NUMERIC desde PostgreSQL 14. Y el de rendimiento, ya conocido: WHERE EXTRACT(YEAR FROM fecha_pedido) = 2025 funciona pero envuelve la columna en una función y no aprovecha el índice; la forma indexable es la de 02-03, WHERE fecha_pedido >= '2025-01-01' AND fecha_pedido < '2026-01-01'. Lo medirás en 08-03.
DATE_TRUNC: el patrón del informe temporal
DATE_TRUNC: el patrón del informe temporalDATE_TRUNC(unidad, fecha) rebaja la fecha al inicio de la unidad pedida: el 22 de octubre truncado a mes es el 1 de octubre. Esa es toda la idea, y es la base de casi todo informe temporal.
| Llamada | Resultado para 2025-10-22 |
|---|---|
DATE_TRUNC('day', f) |
2025-10-22 00:00:00 |
DATE_TRUNC('week', f) |
2025-10-20 00:00:00 (lunes ISO) |
DATE_TRUNC('month', f) |
2025-10-01 00:00:00 |
DATE_TRUNC('quarter', f) |
2025-10-01 00:00:00 |
DATE_TRUNC('year', f) |
2025-01-01 00:00:00 |
Ojo al tipo: aunque le pases un
DATE,DATE_TRUNCdevuelve unTIMESTAMPy por eso ves el00:00:00. Para que el informe muestre una fecha limpia, añade::DATE.
Facturación mensual
SELECT DATE_TRUNC('month', pe.fecha_pedido)::DATE AS mes,
COUNT(DISTINCT pe.id) AS pedidos,
COUNT(*) AS lineas,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY DATE_TRUNC('month', pe.fecha_pedido) ORDER BY mes;| mes | pedidos | lineas | facturacion |
|---|---|---|---|
| 2025-03-01 | 2 | 5 | 68.80 |
| 2025-04-01 | 2 | 5 | 61.28 |
| 2025-05-01 | 2 | 5 | 58.85 |
| 2025-06-01 | 2 | 5 | 95.48 |
| 2025-07-01 | 1 | 3 | 44.60 |
| 2025-08-01 | 1 | 2 | 48.27 |
| 2025-09-01 | 1 | 2 | 32.76 |
| 2025-10-01 | 2 | 5 | 97.20 |
| 2025-11-01 | 1 | 3 | 31.70 |
| 2025-12-01 | 2 | 5 | 64.58 |
| 2026-01-01 | 2 | 4 | 75.10 |
| 2026-02-01 | 2 | 3 | 49.33 |
12 meses, 20 pedidos, 47 líneas y 727,95 € sumando la última columna: la cifra canónica del módulo 4, ahora desglosada. Y los años cuadran: los diez primeros meses suman 603,52 € (2025) y los dos últimos, 124,43 € (2026). El mejor mes es octubre de 2025 con 97,20 €; el peor, noviembre de 2025 con 31,70 €.
Por trimestre
SELECT DATE_TRUNC('quarter', pe.fecha_pedido)::DATE AS trimestre,
COUNT(DISTINCT pe.id) AS pedidos,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY DATE_TRUNC('quarter', pe.fecha_pedido) ORDER BY trimestre;| trimestre | pedidos | facturacion |
|---|---|---|
| 2025-01-01 | 2 | 68.80 |
| 2025-04-01 | 6 | 215.61 |
| 2025-07-01 | 3 | 125.63 |
| 2025-10-01 | 5 | 193.48 |
| 2026-01-01 | 4 | 124.43 |
DATE_TRUNC frente a EXTRACT para agrupar: las dos funcionan pero no son equivalentes. EXTRACT(MONTH FROM f) devuelve 3 para marzo de cualquier año, así que marzo de 2025 y de 2026 caerían en el mismo grupo; DATE_TRUNC('month', f) conserva el año y ordena cronológicamente sin trucos. EXTRACT para estacionalidad, DATE_TRUNC para series temporales.
- Formatear con
TO_CHAR, analizar con TO_DATE
TO_CHAR, analizar con TO_DATETO_CHAR(fecha, patron) convierte una fecha en texto con el formato que quieras.
| Patrón | Qué produce | Para 2025-03-04 |
|---|---|---|
'YYYY-MM-DD' |
ISO | 2025-03-04 |
'DD/MM/YYYY' |
Formato español | 04/03/2025 |
'YYYY-MM' |
Año y mes, ordenable como texto | 2025-03 |
'YYYY"-Q"Q' |
Año y trimestre (texto literal entrecomillado) | 2025-Q1 |
'Month' / 'FMMonth' |
Mes en inglés, con y sin relleno a 9 caracteres | March / March |
'TMMonth' |
Mes traducido según lc_time, sin relleno |
Marzo |
'TMDay' |
Día de la semana traducido | Martes |
'IW' / 'Q' |
Semana ISO / trimestre | 10 / 1 |
Para que la TM funcione en español hay que fijar la configuración regional de la sesión:
SET lc_time = 'es_ES.UTF-8';
SELECT DATE_TRUNC('month', fecha_pedido)::DATE AS mes,
TO_CHAR(fecha_pedido, 'YYYY-MM') AS periodo,
TO_CHAR(fecha_pedido, 'DD/MM/YYYY') AS fecha_es,
TO_CHAR(fecha_pedido, 'TMMonth YYYY') AS mes_largo,
TO_CHAR(fecha_pedido, 'TMDay') AS dia_semana
FROM pedidos WHERE id IN (1, 13, 20) ORDER BY id;| mes | periodo | fecha_es | mes_largo | dia_semana |
|---|---|---|---|---|
| 2025-03-01 | 2025-03 | 04/03/2025 | Marzo 2025 | Martes |
| 2025-10-01 | 2025-10 | 22/10/2025 | Octubre 2025 | Miércoles |
| 2026-02-01 | 2026-02 | 21/02/2026 | Febrero 2026 | Sábado |
Regla de oro: formatea solo al presentar.
TO_CHAR(f, 'DD/MM/YYYY')produce texto, y el texto se ordena alfabéticamente:'01/12/2025'iría antes que'04/03/2025'. Si necesitas ordenar o agrupar, hazlo por la fecha y formatea después. La única excepción segura es'YYYY-MM', que sí ordena bien como texto porque es ISO.
TO_DATE(texto, patron) hace el camino inverso, y es donde vive la ambigüedad clásica:
SELECT TO_DATE('03/04/2025', 'DD/MM/YYYY') AS interpretacion_europea,
TO_DATE('03/04/2025', 'MM/DD/YYYY') AS interpretacion_americana;| interpretacion_europea | interpretacion_americana |
|---|---|
| 2025-04-03 | 2025-03-04 |
El mismo texto, dos fechas distintas, ninguna de las dos incorrecta. Por eso el patrón es obligatorio y por eso el formato ISO AAAA-MM-DD es el único que no se malinterpreta nunca. Cuando importes un CSV, exige el formato en la especificación y no lo deduzcas de los datos: '03/04/2025' no te dirá cuál es.
- Informes sin huecos con
generate_series
generate_seriesAquí se cobra la promesa de 03-06 y de 06-02. Un GROUP BY solo devuelve los grupos que existen en los datos: si en un mes no hubo ninguna alta, ese mes no aparece y el informe miente por omisión. Las altas de clientes por mes, tal cual salen del GROUP BY, dan 8 filas para 13 meses de historia. La solución es fabricar la serie completa de meses y unirla por la izquierda:
SELECT s.mes::DATE AS mes,
COUNT(c.id) AS altas
FROM generate_series(DATE '2025-01-01', DATE '2026-01-01', INTERVAL '1 month') AS s(mes)
LEFT JOIN clientes AS c
ON DATE_TRUNC('month', c.fecha_registro) = s.mes
GROUP BY s.mes
ORDER BY s.mes;| mes | altas |
|---|---|
| 2025-01-01 | 2 |
| 2025-02-01 | 3 |
| 2025-03-01 | 2 |
| 2025-04-01 | 2 |
| 2025-05-01 | 2 |
| 2025-06-01 | 2 |
| 2025-07-01 | 0 |
| 2025-08-01 | 0 |
| 2025-09-01 | 1 |
| 2025-10-01 | 0 |
| 2025-11-01 | 0 |
| 2025-12-01 | 0 |
| 2026-01-01 | 1 |
13 filas y 15 clientes. Ahora se ve lo que el GROUP BY escondía: TiendaVerde captó clientes con regularidad hasta junio de 2025 y se secó durante el segundo semestre —cinco meses con cero altas—. Esa es una conclusión de negocio que sencillamente no existía en el informe de 8 filas.
Tres detalles hacen que el patrón funcione: el LEFT JOIN va de la serie a los datos y nunca al revés; se cuenta COUNT(c.id) y no COUNT(*), porque COUNT(*) contaría la fila fabricada por el LEFT JOIN y daría 1 donde debe dar 0 (04-04); y generate_series con INTERVAL '1 month' genera timestamps, que el ::DATE limpia y el DATE_TRUNC del ON hace comparables. El mismo esquema sirve para días de la semana, trimestres o el eje horario de un panel: es el patrón de informe más reutilizable del curso.
- Tabla comparativa por motor
| Tarea | PostgreSQL 16 | MySQL 8 | SQLite | SQL Server | Oracle |
|---|---|---|---|---|---|
| Fecha de hoy | CURRENT_DATE |
CURDATE() |
DATE('now') |
CAST(GETDATE() AS DATE) |
TRUNC(SYSDATE) |
| Instante actual | NOW(), CURRENT_TIMESTAMP |
NOW() |
DATETIME('now') |
SYSDATETIME() |
SYSTIMESTAMP |
| Diferencia en días | f2 - f1 |
DATEDIFF(f2, f1) |
JULIANDAY(f2) - JULIANDAY(f1) |
DATEDIFF(day, f1, f2) |
f2 - f1 |
| Sumar días | f + 30 |
DATE_ADD(f, INTERVAL 30 DAY) |
DATE(f, '+30 days') |
DATEADD(day, 30, f) |
f + 30 |
| Extraer un componente | EXTRACT(YEAR FROM f) |
EXTRACT, YEAR(f) |
STRFTIME('%Y', f) |
DATEPART(year, f) |
EXTRACT(YEAR FROM f) |
| Truncar a mes | DATE_TRUNC('month', f) |
DATE_FORMAT(f, '%Y-%m-01') |
DATE(f, 'start of month') |
DATETRUNC(month, f) (2022+) |
TRUNC(f, 'MM') |
| Formatear | TO_CHAR(f, 'DD/MM/YYYY') |
DATE_FORMAT(f, '%d/%m/%Y') |
STRFTIME('%d/%m/%Y', f) |
FORMAT(f, 'dd/MM/yyyy') |
TO_CHAR(f, 'DD/MM/YYYY') |
| Analizar texto | TO_DATE(t, 'DD/MM/YYYY') |
STR_TO_DATE(t, '%d/%m/%Y') |
— | PARSE(t AS date …) |
TO_DATE(t, 'DD/MM/YYYY') |
| Tipo con zona horaria | TIMESTAMPTZ |
TIMESTAMP (guarda en UTC) |
no hay tipo fecha | DATETIMEOFFSET |
TIMESTAMP WITH TIME ZONE |
| Serie de fechas | generate_series(d1, d2, '1 day') |
CTE recursiva | CTE recursiva | GENERATE_SERIES (2022+) |
CONNECT BY LEVEL |
Dos advertencias valen por toda la tabla. SQLite no tiene tipo de fecha: guarda texto, números julianos o segundos epoch, y todas sus funciones son STRFTIME sobre texto; funciona, pero nada impide meter '04/03/2025' en la misma columna. Y DATEDIFF de MySQL y de SQL Server llevan los argumentos en orden distinto —DATEDIFF(f2, f1) frente a DATEDIFF(day, f1, f2)—, así que una migración descuidada cambia el signo de todos los resultados.
Y el error que hay que evitar en cualquier motor: guardar fechas como texto. Una columna
VARCHAR(10)con'2025-03-04'acepta'2025-13-45', no se puede sumar ni restar, se ordena mal en cuanto alguien escriba'4/3/2025'y no puede usar un índice de rango con eficacia. Si el dato es una fecha, el tipo esDATE.
Errores Comunes y Consejos
- Guardar fechas como texto. El error raíz.
DATEvalida, ordena, resta e indexa;VARCHARno hace nada de eso. - Usar
TIMESTAMPsin zona en una aplicación con usuarios en varios países.TIMESTAMPTZes la elección por defecto. - Esperar que
date + INTERVAL '1 day'devuelva unDATE. DevuelveTIMESTAMP, igual queDATE_TRUNC: de ahí los00:00:00de los informes. Añade::DATE. - Creer que
INTERVAL '1 month'son 30 días.2025-01-31 + 1 meses2025-02-28. Y no confundasDOW(domingo = 0) conISODOW(lunes = 1). - Agrupar por
EXTRACT(MONTH …)en una serie de varios años. Marzo de 2025 y marzo de 2026 caen en el mismo grupo. Para series temporales,DATE_TRUNC. - Filtrar con
EXTRACT(YEAR FROM f) = 2025. Correcto pero no indexable. Usa>= '2025-01-01' AND < '2026-01-01'(02-03). - Ordenar por una fecha ya formateada con
TO_CHAR. Se ordena como texto. Ordena por la fecha y formatea después;'YYYY-MM'es la única excepción segura. - Interpretar
'03/04/2025'sin especificar el patrón. Son dos fechas distintas según el país. - Contar con
COUNT(*)en un informe congenerate_series. Cuenta la fila fabricada por elLEFT JOIN; cuenta una columna de la tabla de datos. - Consejo: prefiere
>= inicio AND < finaBETWEENen fechas. ConDATEfuncionan igual, pero el día que la columna pase aTIMESTAMP,BETWEENperderá todo el último día salvo las 00:00. - Consejo: guarda en UTC y convierte al presentar, y para depurar fija una fecha de referencia (
DATE '2026-03-01') en vez deCURRENT_DATE: tus resultados serán reproducibles mañana.
Ejercicios
Ejercicio 1
Dirección quiere el informe mensual de 2025 completo. Para cada uno de los doce meses —aunque no haya nada— muestra el mes en formato YYYY-MM, el número de pedidos y la facturación de producto. Usa generate_series y explica por qué enero y febrero aparecen a cero.
Ejercicio 2
Marketing quiere medir la velocidad de conversión: cuántos días pasan entre el registro de un cliente y su primer pedido. Para los clientes 1 a 8, muestra nombre completo, fecha_registro, fecha del primer pedido y días transcurridos, ordenado por días. (Pista: MIN(fecha_pedido) agrupando por cliente.)
Ejercicio 3
Un compañero ha escrito este informe de estacionalidad y afirma que "marzo es nuestro mes más flojo":
-- ⚠️ Sospechosa
SELECT EXTRACT(MONTH FROM fecha_pedido) AS mes, COUNT(*) AS pedidos
FROM pedidos GROUP BY EXTRACT(MONTH FROM fecha_pedido) ORDER BY mes;- ¿Qué está midiendo realmente y por qué la conclusión es frágil con los datos de TiendaVerde?
- Escribe la versión con
DATE_TRUNCque responde a "¿cómo evoluciona el negocio mes a mes?". - ¿En qué caso sí sería correcta la consulta original?
Soluciones
Solución 1
SELECT TO_CHAR(s.mes, 'YYYY-MM') AS mes,
COUNT(DISTINCT pe.id) AS pedidos,
COALESCE(ROUND(SUM(lp.cantidad * lp.precio_unitario
* (1 - lp.descuento)), 2), 0) AS facturacion
FROM generate_series(DATE '2025-01-01', DATE '2025-12-01', INTERVAL '1 month') AS s(mes)
LEFT JOIN pedidos AS pe ON DATE_TRUNC('month', pe.fecha_pedido) = s.mes
LEFT JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY s.mes
ORDER BY s.mes;| mes | pedidos | facturacion |
|---|---|---|
| 2025-01 | 0 | 0.00 |
| 2025-02 | 0 | 0.00 |
| 2025-03 | 2 | 68.80 |
| 2025-04 | 2 | 61.28 |
| 2025-05 | 2 | 58.85 |
| 2025-06 | 2 | 95.48 |
| 2025-07 | 1 | 44.60 |
| 2025-08 | 1 | 48.27 |
| 2025-09 | 1 | 32.76 |
| 2025-10 | 2 | 97.20 |
| 2025-11 | 1 | 31.70 |
| 2025-12 | 2 | 64.58 |
12 filas, 16 pedidos y 603,52 €: la facturación de 2025 del módulo 4. Enero y febrero salen a cero porque el primer pedido de TiendaVerde es del 4 de marzo de 2025: la tienda existía pero aún no vendía. Sin generate_series esos dos meses no aparecerían y un gráfico de líneas empezaría en marzo, dando a entender que no hay historia anterior. El COALESCE (06-04) convierte el NULL de SUM sobre un conjunto vacío en 0.00.
Solución 2
SELECT c.id, CONCAT_WS(' ', c.nombre, c.apellidos) AS cliente, c.fecha_registro,
MIN(pe.fecha_pedido) AS primer_pedido,
MIN(pe.fecha_pedido) - c.fecha_registro AS dias
FROM clientes AS c
JOIN pedidos AS pe ON pe.cliente_id = c.id
WHERE c.id <= 8
GROUP BY c.id, c.nombre, c.apellidos, c.fecha_registro ORDER BY dias;| id | cliente | fecha_registro | primer_pedido | dias |
|---|---|---|---|---|
| 2 | Carlos Ferrer Ibáñez | 2025-01-22 | 2025-03-12 | 49 |
| 1 | Lucía Martínez Soler | 2025-01-10 | 2025-03-04 | 53 |
| 3 | Marta Sanchis Gil | 2025-02-03 | 2025-04-02 | 58 |
| 4 | Javier Ortega Ruiz | 2025-02-14 | 2025-04-19 | 64 |
| 5 | Ana Belmonte Roca | 2025-02-27 | 2025-05-23 | 85 |
| 6 | Pau Llorens Vidal | 2025-03-09 | 2025-06-11 | 94 |
| 7 | Sofia Moreira Costa | 2025-03-21 | 2025-06-28 | 99 |
| 8 | Tiago Almeida Nunes | 2025-04-04 | 2025-07-15 | 102 |
La lectura de negocio es incómoda: la conversión empeora conforme avanza el año, de 49 a 102 días. Y hay un sesgo que declarar: los clientes recientes han tenido menos tiempo para comprar. Con JOIN normal desaparecen además los clientes 13, 14 y 15, que no han pedido nunca; con LEFT JOIN aparecerían con NULL en las dos últimas columnas, que es la respuesta honesta.
Solución 3
1. EXTRACT(MONTH …) devuelve 3 tanto para marzo de 2025 como para marzo de 2026, así que mide estacionalidad, no evolución: agrupa todos los marzos de la historia. Con los datos de TiendaVerde la conclusión es frágil porque solo hay un marzo con datos y doce meses de historia, de modo que cada "mes del año" tiene entre uno y dos pedidos. Con 20 pedidos en 12 meses no hay estacionalidad que medir: hay ruido.
2. La serie temporal es la consulta de la sección 5, y su lectura es la contraria: meses buenos y malos alternos, máximo en octubre de 2025 (97,20 €) y mínimo en noviembre (31,70 €), sin tendencia clara.
3. Sería correcta si la pregunta fuera de verdad estacional —"¿en qué mes del año vendemos más, promediando varios años?"— y hubiera suficientes años para promediar. Lo incorrecto no es la función: es usarla para responder a una pregunta de evolución.
Conclusión
Ya sabes trabajar con el tiempo:
- Conoces los cinco tipos temporales y por qué TiendaVerde usa
DATE, con la recomendación firme de que en una aplicación realTIMESTAMPTZes casi siempre la elección correcta. Y distinguesNOW()—congelada durante toda la transacción— deCLOCK_TIMESTAMP(). - Haces aritmética de fechas sabiendo que
date - dateda días enteros, quedate + INTERVALdaTIMESTAMPy queINTERVAL '1 month'no son 30 días. Y usasAGE()cuando el destinatario es una persona. - Sacas componentes con
EXTRACT(DOWempieza en domingo,WEEKes ISO) y truncas conDATE_TRUNC, que convierte 20 pedidos en una serie mensual — la deuda de 04-05, saldada. Y sabes cuándo usar cada uno:EXTRACTpara estacionalidad,DATE_TRUNCpara evolución. - Formateas con
TO_CHAR(incluidoTMMonthconlc_time) y analizas conTO_DATE, recordando que'03/04/2025'son dos fechas distintas y que ordenar texto no es ordenar fechas. - Construyes informes sin huecos con
generate_series+LEFT JOIN+COUNT(columna), y has visto lo que escondían: cinco meses seguidos sin una sola alta de cliente.
Queda un cabo suelto que has visto tres veces en esta lección y no has podido atar: en el informe de altas por mes hizo falta COUNT(c.id) en lugar de COUNT(*) para que saliera 0; en el de facturación mensual hizo falta un COALESCE para que un mes vacío no mostrara NULL; y en el listado de clientes sin pedidos, las columnas de fecha aparecerían vacías sin decir por qué. Los tres son el mismo problema —qué hacer cuando no hay valor— y la lección siguiente, conversión de tipos y manejo de NULL, lo resuelve definitivamente con CAST, COALESCE y NULLIF. Además cerrará otra cuenta pendiente: has estado escribiendo ::DATE, ::NUMERIC y ::TEXT durante tres lecciones sin que nadie te explicara qué son.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
