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

  1. Los tipos temporales, y por qué TiendaVerde usa DATE
  2. El "ahora": CURRENT_DATE, NOW() y CLOCK_TIMESTAMP()
  3. Aritmética de fechas, INTERVAL y AGE()
  4. EXTRACT y DATE_PART
  5. DATE_TRUNC: el patrón del informe temporal
  6. Formatear con TO_CHAR, analizar con TO_DATE
  7. Informes sin huecos con generate_series
  8. Tabla comparativa por motor
  9. Errores Comunes y Consejos
  10. Ejercicios
  11. Conclusión

  1. Los tipos temporales, y por qué TiendaVerde usa 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 temporalesfecha_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, TIMESTAMPTZ es casi siempre la elección correcta. TIMESTAMP sin 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. TIMESTAMPTZ guarda internamente un instante en UTC y lo convierte a la zona de cada sesión al leerlo. Y DATE es 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.

  1. El "ahora": 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 (el DEFAULT de clientes en 01-06) es estable y correcto. Un DEFAULT clock_timestamp() sería volátil y, como viste en 05-06, obligaría a reescribir la tabla entera al añadir la columna.

  1. Aritmética de fechas, 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.

  1. EXTRACT y DATE_PART

EXTRACT(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.

  1. DATE_TRUNC: el patrón del informe temporal

DATE_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_TRUNC devuelve un TIMESTAMP y por eso ves el 00: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.

  1. Formatear con TO_CHAR, analizar con TO_DATE

TO_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 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.

  1. Informes sin huecos con generate_series

Aquí 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.

  1. 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 distintoDATEDIFF(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 es DATE.

Errores Comunes y Consejos

  • Guardar fechas como texto. El error raíz. DATE valida, ordena, resta e indexa; VARCHAR no hace nada de eso.
  • Usar TIMESTAMP sin zona en una aplicación con usuarios en varios países. TIMESTAMPTZ es la elección por defecto.
  • Esperar que date + INTERVAL '1 day' devuelva un DATE. Devuelve TIMESTAMP, igual que DATE_TRUNC: de ahí los 00:00:00 de los informes. Añade ::DATE.
  • Creer que INTERVAL '1 month' son 30 días. 2025-01-31 + 1 mes es 2025-02-28. Y no confundas DOW (domingo = 0) con ISODOW (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 con generate_series. Cuenta la fila fabricada por el LEFT JOIN; cuenta una columna de la tabla de datos.
  • Consejo: prefiere >= inicio AND < fin a BETWEEN en fechas. Con DATE funcionan igual, pero el día que la columna pase a TIMESTAMP, BETWEEN perderá 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 de CURRENT_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;
  1. ¿Qué está midiendo realmente y por qué la conclusión es frágil con los datos de TiendaVerde?
  2. Escribe la versión con DATE_TRUNC que responde a "¿cómo evoluciona el negocio mes a mes?".
  3. ¿En qué caso 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 real TIMESTAMPTZ es casi siempre la elección correcta. Y distingues NOW() —congelada durante toda la transacción— de CLOCK_TIMESTAMP().
  • Haces aritmética de fechas sabiendo que date - date da días enteros, que date + INTERVAL da TIMESTAMP y que INTERVAL '1 month' no son 30 días. Y usas AGE() cuando el destinatario es una persona.
  • Sacas componentes con EXTRACT (DOW empieza en domingo, WEEK es ISO) y truncas con DATE_TRUNC, que convierte 20 pedidos en una serie mensual — la deuda de 04-05, saldada. Y sabes cuándo usar cada uno: EXTRACT para estacionalidad, DATE_TRUNC para evolución.
  • Formateas con TO_CHAR (incluido TMMonth con lc_time) y analizas con TO_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

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados