En cualquier sistema administrativo, ERP, CRM o plataforma de facturación electrónica en México, hay una pregunta recurrente que el área de ventas, contabilidad o cobranza hace a cada momento: “¿Cuál fue la última factura emitida a cada cliente, en qué fecha y por qué importe?”.
A nivel de base de datos relacional (MySQL o MariaDB), parece una consulta sencilla: basta con agrupar por cliente y pedir la fecha máxima. Sin embargo, este es uno de los tropiezos más comunes y engañosos del desarrollo de software: muchos queries que parecen correctos terminan entregando importes de facturas equivocadas sin mandar ningún aviso de advertencia.
En este artículo te mostramos por qué la solución tradicional con GROUP BY falla silenciosamente, cómo MySQL 8 protege la integridad de tus datos y cómo resolverlo de forma limpia, exacta y profesional usando la función de ventana ROW_NUMBER().
El problema: la trampa del GROUP BY tradicional
Imagina una tabla clásica donde guardas los comprobantes fiscales digitales emitidos (facturas), identificados por el RFC del receptor, la fecha de emisión y el importe total:
CREATE TABLE facturas (
id INT AUTO_INCREMENT PRIMARY KEY,
rfc_receptor VARCHAR(13) NOT NULL,
fecha DATE NOT NULL,
total DECIMAL(12,2) NOT NULL
);
INSERT INTO facturas (rfc_receptor, fecha, total) VALUES
('XAXX010101000', '2026-09-01', 1160.00),
('XAXX010101000', '2026-09-20', 580.00),
('EKU9003173C9', '2026-08-15', 2320.00),
('EKU9003173C9', '2026-09-28', 348.00),
('EKU9003173C9', '2026-09-10', 116.00);
❌ Lo que muchos intentan hacer
La primera intuición suele ser escribir una consulta como esta:
SELECT rfc_receptor, MAX(fecha) AS fecha, total
FROM facturas
GROUP BY rfc_receptor;
A primera vista parece lógica: agrupamos por RFC del cliente, calculamos la fecha más reciente con MAX(fecha) y seleccionamos el total. Pero aquí está la trampa:
-
En servidores antiguos o sin
ONLY_FULL_GROUP_BY: MySQL calcula correctamente la fecha máxima, pero para la columnatotal(que no forma parte delGROUP BYni está envuelta en una función de agregación) el motor toma un valor arbitrario del grupo.El resultado real de esa consulta es una mezcla desastrosa:
+---------------+------------+---------+ | rfc_receptor | fecha | total | +---------------+------------+---------+ | EKU9003173C9 | 2026-09-28 | 2320.00 | <-- ¡La factura del 28/09 era de $348.00! | XAXX010101000 | 2026-09-20 | 1160.00 | <-- ¡La factura del 20/09 era de $580.00! +---------------+------------+---------+El reporte arroja la fecha del comprobante más nuevo con el importe de una factura anterior. En un reporte de cobranza o estados de cuenta de clientes, esto provoca discrepancias graves.
-
En MySQL 8.0+ y MariaDB modernas (con
ONLY_FULL_GROUP_BY): El motor detecta de inmediato la ambigüedad y detiene la consulta con el error 1055:ERROR 1055 (42000): Expression #3 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'facturas.total' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
¡Cuidado con la tentación de desactivar
ONLY_FULL_GROUP_BY!
Algunos tutoriales sugieren “solucionar” el error 1055 eliminando esta directiva de la configuración (my.cnfosql_mode). Eso no soluciona el problema de fondo: únicamente silencia la alerta y permite que tu base de datos devuelva reportes con datos falsos.
La solución correcta: Funciones de ventana con ROW_NUMBER()
A partir de MySQL 8.0 y MariaDB 10.2, el estándar SQL incorporó de forma nativa las funciones de ventana (Window Functions). A diferencia de GROUP BY, que colapsa todas las filas en un solo resumen, las funciones de ventana realizan cálculos sobre un conjunto de filas relacionadas sin perder el detalle individual de cada registro.
Para obtener la última factura, la función ideal es ROW_NUMBER():
SELECT rfc_receptor, fecha, total
FROM (
SELECT
id,
rfc_receptor,
fecha,
total,
ROW_NUMBER() OVER (
PARTITION BY rfc_receptor
ORDER BY fecha DESC, id DESC
) AS rn
FROM facturas
) AS t
WHERE rn = 1;
O si prefieres una sintaxis moderna y legible con Common Table Expressions (CTE / WITH):
WITH facturas_numeradas AS (
SELECT
id,
rfc_receptor,
fecha,
total,
ROW_NUMBER() OVER (
PARTITION BY rfc_receptor
ORDER BY fecha DESC, id DESC
) AS rn
FROM facturas
)
SELECT rfc_receptor, fecha, total
FROM facturas_numeradas
WHERE rn = 1;
¿Cómo funciona paso a paso?
PARTITION BY rfc_receptor: Divide el conjunto de facturas en grupos o “cajones” independientes, uno por cada cliente o RFC.ORDER BY fecha DESC, id DESC: Dentro de cada cajón, ordena las facturas de la más reciente a la más antigua. Agregarid DESCasegura un criterio de desempate en caso de que existan dos facturas emitidas el mismo día.ROW_NUMBER(): Asigna una numeración secuencial (1, 2, 3…) a cada factura dentro de su cajón. La factura más reciente siempre recibe el número1.WHERE rn = 1: En el query exterior, simplemente filtramos aquellas filas que tienen la posición número 1.
✅ Resultado exacto y consistente
+---------------+------------+--------+
| rfc_receptor | fecha | total |
+---------------+------------+--------+
| EKU9003173C9 | 2026-09-28 | 348.00 | <-- Fecha correcta y monto real
| XAXX010101000 | 2026-09-20 | 580.00 | <-- Fecha correcta y monto real
+---------------+------------+--------+
Todos los datos pertenecen a la misma fila física, preservando la coherencia del registro.
¿Y si utilizas una versión anterior a MySQL 8.0?
Si administras un servidor heredado con MySQL 5.7 (que ya alcanzó el fin de su ciclo de soporte oficial) y no cuentas con funciones de ventana, la alternativa segura es realizar una subconsulta con INNER JOIN:
SELECT f.rfc_receptor, f.fecha, f.total
FROM facturas f
INNER JOIN (
SELECT rfc_receptor, MAX(fecha) AS max_fecha
FROM facturas
GROUP BY rfc_receptor
) ultimas
ON f.rfc_receptor = ultimas.rfc_receptor
AND f.fecha = ultimas.max_fecha;
Advertencia de duplicados:
Si un cliente tiene dos facturas emitidas en la misma fecha máxima, este método devolverá ambas. Para evitar duplicados en versiones antiguas debías agrupar porMAX(id)o anidar subconsultas adicionales, lo que degrada el rendimiento en tablas con miles de transacciones. Por esoROW_NUMBER()es la alternativa estándar en la actualidad.
Más allá de la última factura: casos prácticos en tu negocio
La ventaja de dominar ROW_NUMBER() es que este mismo patrón resuelve decenas de necesidades en sistemas administrativos y de facturación:
- Obtener las últimas 3 compras de cada cliente: Basta con cambiar el filtro final a
WHERE rn <= 3. En SQL tradicional con subconsultas, lograr esto era sumamente complejo y lento. - Último complemento de pago emitido: Para saber qué clientes tienen pagos recientes registrados en parcialidades o diferidos (PPD).
- Último inicio de sesión de usuarios: Conocer la fecha y dirección IP del acceso más reciente en un panel administrativo.
- Último movimiento de inventario: Identificar la fecha y precio del lote más reciente por código de producto.
ROW_NUMBER() vs. RANK() vs. DENSE_RANK()
MySQL ofrece tres funciones similares para ordenar posiciones. Esta es la diferencia práctica cuando hay empates en fecha:
| Función | Comportamiento ante empates | Ejemplo de salida | Ideal para |
|---|---|---|---|
ROW_NUMBER() | Asigna números únicos consecutivos (1, 2, 3…) sin empates. | 1, 2, 3, 4 | Quedarse con exactamente una fila por grupo. |
RANK() | Asigna la misma posición a valores iguales y salta números. | 1, 1, 3, 4 | Cuadros de honor o rankings deportivos. |
DENSE_RANK() | Asigna la misma posición a valores iguales sin saltar números. | 1, 1, 2, 3 | Clasificaciones densas por puntaje. |
Para obtener el último registro de una entidad, ROW_NUMBER() es siempre la opción indicada.
Tabla comparativa de enfoques en MySQL
| Criterio | GROUP BY con columnas libres | INNER JOIN con MAX() | ROW_NUMBER() (Ventana) |
|---|---|---|---|
| Integridad de datos | ❌ Falso (mezcla columnas) | ✅ Preciso | ✅ 100% Preciso y consistente |
| Manejo de empates | ❌ Incontrolable | ⚠️ Requiere agrupar por MAX(id) | ✅ Desempate limpio con ORDER BY |
| Flexibilidad (últimas N filas) | ❌ Imposible | ❌ Muy complejo | ✅ Inmediato (WHERE rn <= N) |
| Compatibilidad | MySQL < 8.0 sin strict mode | Todas las versiones | MySQL 8.0+ / MariaDB 10.2+ |
| Rendimiento | Engañoso | Regular (doble lectura) | Óptimo con índices adecuados |
Checklist para optimizar consultas de facturación
Antes de enviar esta consulta a producción en tu sistema ERP o reporte web, revisa estos puntos clave:
- Mantén activo
ONLY_FULL_GROUP_BY: Asegúrate de que el modo SQL de tu servidor prevenga consultas ambiguas por defecto. - Crea un índice compuesto: Si la tabla
facturastiene decenas o cientos de miles de registros, crea un índice que cubra la partición y el ordenamiento:CREATE INDEX idx_facturas_cliente_fecha ON facturas (rfc_receptor, fecha DESC, id DESC); - Desempata siempre con la clave primaria: Incluye el campo
idautoincremental al final deORDER BYpara que el orden sea 100% determinista. - Verifica el tipo de dato de fecha: Si tu campo almacena fecha y hora (
DATETIMEoTIMESTAMP), el ordenamiento por fecha reflejará con mayor precisión el segundo exacto de la emisión del CFDI. - Usa
DECIMALpara montos contables: Nunca usesFLOATniDOUBLEpara almacenar subtotales o totales en facturación.
Simplifica la facturación de tu empresa con IOSoluciones
Si desarrollas sistemas a medida, administras bases de datos de clientes o gestionas la facturación de una o varias empresas, contar con una arquitectura sólida te ahorra miles de horas y evita discrepancias contables.
- ¿Buscas un sistema de facturación electrónica confiable y multi-RFC?
Conoce Orbis Facturación Electrónica, nuestra plataforma en la nube diseñada para PyMEs y profesionistas en México. Emite facturas CFDI 4.0, complementos de pago y notas de crédito en segundos, sin complicaciones técnicas. - ¿Necesitas optimización de bases de datos o desarrollo de sistemas?
En IOSoluciones brindamos servicios de Soporte Técnico y Mantenimiento y desarrollo de software para mantener tus aplicaciones seguras, rápidas y escalables.
¿Tienes dudas sobre cómo estructurar tus reportes o implementar facturación en tu plataforma? Contáctanos y con gusto platicamos sobre tu proyecto.
Fuentes oficiales
Consultadas en octubre de 2026:
- MySQL 8.4 Reference Manual, “Window Function Descriptions” (
ROW_NUMBER()y sintaxisOVER): dev.mysql.com/doc/refman/8.4/en/window-function-descriptions.html - MySQL 8.4 Reference Manual, “MySQL Handling of GROUP BY” (
ONLY_FULL_GROUP_BYy control de no determinismo): dev.mysql.com/doc/refman/8.4/en/group-by-handling.html - MariaDB Knowledge Base, “Window Functions Overview” (soporte disponible desde MariaDB 10.2): mariadb.com/kb/en/window-functions-overview/