Cómo crear una factless fact table en un Data Warehouse
Una factless fact table (tabla de hechos sin hechos) es una tabla de hechos que no tiene medidas numéricas: solo guarda las claves de las dimensiones que participan en un evento. Parece extraño, pero es la forma más limpia de contar acontecimientos — clases asistidas, candidaturas, clics — y la única forma práctica de responder a la pregunta más difícil de un Data Warehouse: lo que no ocurrió. Aquí tienes el paso a paso para crear una factless fact table en un Data Warehouse, cargarla y usarla en consultas reales.
Requisitos previos
- Un Data Warehouse con un esquema en estrella ya iniciado (SQL Server, Azure SQL, Fabric Warehouse, Synapse u otro motor SQL).
- Al menos dos dimensiones con surrogate keys (por ejemplo
DimStudentyDimCourse) y unaDimDate. - Una tabla de staging con los eventos en bruto (
stg_Attendance). - Permisos para crear tablas y ejecutar INSERT, y SQL básico (CREATE TABLE, JOIN, GROUP BY).
Paso 1: Saber cuándo necesitas una factless fact table
Hay dos escenarios clásicos, y conviene distinguirlos antes de escribir código:
- Eventos: algo ocurrió y no hay nada que sumar. Un alumno asistió a una clase. No existe un "valor" de la asistencia — la asistencia es el hecho. La medida es simplemente el recuento de filas.
- Cobertura: registras lo que podría haber ocurrido. Qué alumnos están matriculados en qué cursos, qué productos están en promoción en qué tiendas. Por sí sola dice poco; cruzada con la tabla de eventos, revela las ausencias.
Un error habitual es inventar una columna Quantity siempre igual a 1 solo para que "la tabla tenga un hecho". No hace falta: COUNT(*) hace el mismo trabajo y ahorra espacio.
Paso 2: Definir el proceso de negocio y el grano
El grano es la frase que describe exactamente qué representa una fila. Escríbelo antes del DDL — si no lo puedes escribir en una sola frase, el modelo todavía no está listo.
Grano: una fila por alumno, por curso, por día en el que el alumno asistió a la clase.
Este grano te dice de inmediato tres cosas: las dimensiones implicadas (alumno, curso, fecha), la clave primaria de la tabla y qué cuenta como duplicado.
Paso 3: Crear la tabla
La tabla solo tiene claves foráneas. La clave primaria se compone de las tres claves — eso es lo que garantiza el grano e impide duplicados.
CREATE TABLE FactAttendance (
StudentKey INT NOT NULL,
CourseKey INT NOT NULL,
DateKey INT NOT NULL,
CONSTRAINT PK_FactAttendance
PRIMARY KEY (StudentKey, CourseKey, DateKey),
CONSTRAINT FK_FactAttendance_Student
FOREIGN KEY (StudentKey) REFERENCES DimStudent(StudentKey),
CONSTRAINT FK_FactAttendance_Course
FOREIGN KEY (CourseKey) REFERENCES DimCourse(CourseKey),
CONSTRAINT FK_FactAttendance_Date
FOREIGN KEY (DateKey) REFERENCES DimDate(DateKey)
);
Fíjate: ninguna columna de medida. Es normal y es justamente el objetivo.
Paso 4: Cargar los datos
La carga busca las surrogate keys en las dimensiones e inserta solo lo que aún no existe, para que puedas volver a ejecutarla sin duplicar filas (carga idempotente).
INSERT INTO FactAttendance (StudentKey, CourseKey, DateKey)
SELECT DISTINCT s.StudentKey, c.CourseKey, d.DateKey
FROM stg_Attendance a
JOIN DimStudent s ON s.StudentCode = a.StudentCode
JOIN DimCourse c ON c.CourseCode = a.CourseCode
JOIN DimDate d ON d.FullDate = CAST(a.AttendedAt AS date)
WHERE NOT EXISTS (
SELECT 1
FROM FactAttendance f
WHERE f.StudentKey = s.StudentKey
AND f.CourseKey = c.CourseKey
AND f.DateKey = d.DateKey
);
Si un JOIN falla (un alumno que aún no existe en DimStudent), la fila simplemente se descarta. Para no perder eventos en silencio, cambia a LEFT JOIN y envía esas filas a una tabla de rechazados, o usa una fila "Desconocido" en la dimensión.
Paso 5: Contar eventos
Sin medidas, el análisis se hace con recuentos. Aquí es donde la tabla demuestra su valor:
SELECT c.CourseName,
d.MonthName,
COUNT(*) AS Attendances,
COUNT(DISTINCT f.StudentKey) AS DistinctStudents
FROM FactAttendance f
JOIN DimCourse c ON c.CourseKey = f.CourseKey
JOIN DimDate d ON d.DateKey = f.DateKey
WHERE d.CalendarYear = 2026
GROUP BY c.CourseName, d.MonthName
ORDER BY Attendances DESC;
Paso 6: Descubrir lo que no ocurrió
Aquí entra la segunda tabla factless: FactEnrollment, con el grano "una fila por alumno matriculado en un curso". Al cruzar la cobertura (matrículas) con los eventos (asistencias) aparecen los alumnos que nunca asistieron a una clase — una pregunta imposible de responder solo con la tabla de asistencias, porque la ausencia no genera filas.
SELECT s.StudentName, c.CourseName
FROM FactEnrollment e
JOIN DimStudent s ON s.StudentKey = e.StudentKey
JOIN DimCourse c ON c.CourseKey = e.CourseKey
LEFT JOIN FactAttendance f
ON f.StudentKey = e.StudentKey
AND f.CourseKey = e.CourseKey
WHERE f.StudentKey IS NULL
ORDER BY c.CourseName, s.StudentName;
Verificar el resultado
Tres comprobaciones rápidas antes de dar la tabla por terminada:
- Grano respetado: el número de filas debe ser igual al número de combinaciones distintas. Si creaste la PRIMARY KEY, el motor ya lo garantiza — una segunda carga no puede insertar duplicados.
- Sin claves huérfanas:
SELECT COUNT(*) FROM FactAttendance f LEFT JOIN DimStudent s ON s.StudentKey = f.StudentKey WHERE s.StudentKey IS NULL;debe devolver 0. - Los totales cuadran: compara
COUNT(*)en la tabla factless con el número de eventos distintos en staging. Si hay diferencia, la causa casi siempre es un JOIN que falló en el Paso 4.
Ejecuta la carga dos veces seguidas: el total debe quedar exactamente igual. Si crece, el error está en el NOT EXISTS o en el grano.
Conclusión
Con media docena de columnas ya tienes una tabla que cuenta eventos de forma barata y que, junto con la tabla de cobertura, responde a preguntas de ausencia que suelen quedarse sin respuesta. El siguiente paso natural es conectar ambas a Power BI y crear dos medidas en DAX — asistencias y tasa de asistencia — sobre este mismo modelo. Y en tu negocio, ¿cuál es el evento que hoy nadie puede contar simplemente porque no tiene ningún valor asociado?