Recipe Tableau and SQL
Why don't my distinct counts add up to the total?
A distinct count isn't additive: a member who takes yoga and spin counts once in each class row but once in the total. At this gym the class counts add up to 532 while the gym has 310 members, and monthly active members add up to 1,156. Both numbers are right; they count different things.
The data
Harbor Strength gym, class check-ins in 2025
A gym with three studios and five class types. 310 members, 2,055 check-ins over 2025. Most members stick to their home studio but mix two or three kinds of class.
checkin_id | checkin_date | month | member_id | studio | class_type |
|---|---|---|---|---|---|
| C-00001 | 2025-01-03 | 2025-01 | M-0020 | Midtown | HIIT |
| C-00002 | 2025-01-03 | 2025-01 | M-0167 | Midtown | Spin |
| C-00003 | 2025-01-03 | 2025-01 | M-0246 | Eastside | Strength |
| C-00004 | 2025-01-04 | 2025-01 | M-0044 | Harbor | Spin |
Why is the sum bigger than the total?
Because people belong to more than one group. Harbor Strength has 310 members. Break them down by class type and the bars add up to 532, because most members mix two or three kinds of class. Break them down by month and the bars add up to 1,156: a member who came all year sits in twelve bars. Neither number is a bug. The bars count member-group pairs; the total counts people.
Step 1
Count distinct members per group
Each bar is the number of different members who checked in at least once in that group. Every bar is correct on its own.
Tableau
COUNTD([member_id])
// with [{knob.grain}] on RowsThe xmetric engine ran this formula and got the SQL’s Members column, row for row.
SQL
SELECT
class_type,
COUNT(DISTINCT member_id) : : INTEGER AS members
FROM input
GROUP BY class_type
ORDER BY members DESC, class_typeRows
| Class type | Members |
|---|---|
| Strength | 142 |
| Yoga | 132 |
| Spin | 120 |
| HIIT | 101 |
| Mobility | 37 |
Step 2
Add the bars, then count the whole gym
The sum is always at least the distinct count, and equal only when nobody appears in two groups. The INCLUDE expression counts per group first and adds the results, so it gives the sum on purpose; plain COUNTD over the whole table gives the people.
Tableau
SUM({ INCLUDE [class_type] : COUNTD([member_id]) })
// vs COUNTD([member_id])SQL
WITH p AS (
SELECT class_type, COUNT(DISTINCT member_id) AS members
FROM input
GROUP BY class_type
)
SELECT
'Sum of the bars' AS total,
SUM(members) : : INTEGER AS members
FROM p
UNION ALL
SELECT 'Distinct members', COUNT(DISTINCT member_id) : : INTEGER
FROM inputRows
| Total | Members |
|---|---|
| Sum of the bars | 532 |
| Distinct members | 310 |
How much does the grain change it?
Switch the breakdown. By {{grain}}, the bars add up to 532 against 310 real members, 71.6% too many. Studios overlap least, since 241 members never leave their home studio. Months overlap most, because the same people come back month after month. The finer or more repeatable the group, the further the sum drifts from the people.
Step 3
See how many groups each member is in
This is where the extra comes from. A member in three groups is counted three times by the bars and once by the total. Add up times_counted and you get exactly the sum of the bars.
Tableau
{ FIXED [member_id] : COUNTD([class_type]) }
// as a dimension on Columns, with COUNTD([member_id]) on RowsSQL
WITH m AS (
SELECT
member_id,
COUNT(DISTINCT class_type) AS groups_per_member
FROM input
GROUP BY member_id
)
SELECT
groups_per_member : : INTEGER AS groups_per_member,
COUNT(*) : : INTEGER AS members,
(groups_per_member * COUNT(*)) : : INTEGER AS times_counted
FROM m
GROUP BY groups_per_member
ORDER BY groups_per_memberRows
| Groups per member | Members | Times counted |
|---|---|---|
| 1 | 137 | 137 |
| 2 | 124 | 248 |
| 3 | 49 | 147 |
Step 4
Split each group into its own members and shared ones
The Only here part of each bar is members who appear in no other group. Only those add up cleanly; the shared part is counted again in some other bar. FIXED works here because it ignores the groups on the view and looks at each member across the whole table.
Tableau
IF { FIXED [member_id] : COUNTD([class_type]) } = 1 THEN "Only here"
ELSE "Also elsewhere"
END
// on Color, with COUNTD([member_id])SQL
WITH m AS (
SELECT
member_id,
COUNT(DISTINCT class_type) AS groups_per_member
FROM input
GROUP BY member_id
), j AS (
SELECT DISTINCT i.class_type, i.member_id, m.groups_per_member
FROM input i JOIN m USING (member_id)
)
SELECT
class_type,
CASE
WHEN groups_per_member = 1 THEN 'Only here'
ELSE 'Also elsewhere'
END AS reach,
COUNT(*) : : INTEGER AS members
FROM j
GROUP BY ALL
ORDER BY class_type, reach DESCRows
| Class type | Reach | Members |
|---|---|---|
| HIIT | Only here | 28 |
| HIIT | Also elsewhere | 73 |
| Mobility | Only here | 9 |
| Mobility | Also elsewhere | 28 |
| Spin | Only here | 26 |
| Spin | Also elsewhere | 94 |
| Strength | Only here | 36 |
| Strength | Also elsewhere | 106 |
| Yoga | Only here | 38 |
| Yoga | Also elsewhere | 94 |