Feature Adoption and Product Engagement

Feature Adoption and Product Engagement

Product analytics answers: which features drive retention, who are the power users, and which accounts are zombies (subscribed but not using the product)?


1. Feature Adoption Rate

Adoption = accounts using a feature at least once / total active accounts:

WITH active_accounts AS (
    SELECT COUNT(*) AS total FROM saas_accounts
    WHERE status = 'active'
),
feature_adopters AS (
    SELECT
        feature_name,
        COUNT(DISTINCT account_id) AS adopters,
        SUM(usage_count)           AS total_uses,
        ROUND(AVG(usage_count), 1) AS avg_uses_per_account
    FROM saas_feature_usage
    WHERE usage_count > 0
    GROUP BY feature_name
)
SELECT
    fa.feature_name,
    fa.adopters,
    fa.total_uses,
    fa.avg_uses_per_account,
    ROUND(100.0 * fa.adopters / aa.total, 1) AS adoption_rate_pct
FROM feature_adopters fa
CROSS JOIN active_accounts aa
ORDER BY adoption_rate_pct DESC;

What This Returns

feature_name  | adopters | total_uses | adoption_rate_pct
--------------+----------+------------+------------------
Dashboard     |      275 |      14800 |              79.7
Reports       |      268 |      14300 |              77.7
Exports       |      260 |      13900 |              75.4
...

2. Zero-Usage Accounts (Zombie Accounts — Churn Risk)

SELECT
    a.id,
    a.name,
    a.acquisition_channel,
    p.name    AS plan,
    a.mrr_cents / 100 AS mrr_usd,
    a.converted_at::DATE,
    COALESCE(SUM(fu.usage_count), 0) AS total_usage_last_90d
FROM saas_accounts a
JOIN saas_plans p ON p.id = a.plan_id
LEFT JOIN saas_feature_usage fu
    ON  fu.account_id = a.id
    AND fu.period_date >= CURRENT_DATE - INTERVAL '90 days'
WHERE a.status = 'active'
GROUP BY a.id, a.name, a.acquisition_channel, p.name, a.mrr_cents, a.converted_at
HAVING COALESCE(SUM(fu.usage_count), 0) = 0
ORDER BY a.mrr_cents DESC;

3. Power User Identification

Power users are in the top decile of feature usage:

WITH account_usage AS (
    SELECT
        account_id,
        SUM(usage_count) AS total_usage
    FROM saas_feature_usage
    GROUP BY account_id
),
deciles AS (
    SELECT
        account_id,
        total_usage,
        NTILE(10) OVER (ORDER BY total_usage DESC) AS usage_decile
    FROM account_usage
)
SELECT
    d.account_id,
    a.name,
    a.plan_id,
    p.name AS plan,
    d.total_usage,
    d.usage_decile
FROM deciles d
JOIN saas_accounts a ON a.id = d.account_id
JOIN saas_plans p    ON p.id = a.plan_id
WHERE d.usage_decile = 1   -- top 10%
ORDER BY d.total_usage DESC;

4. Feature Co-Usage — Which Features Are Used Together?

SELECT
    f1.feature_name AS feature_a,
    f2.feature_name AS feature_b,
    COUNT(DISTINCT f1.account_id) AS co_usage_accounts
FROM saas_feature_usage f1
JOIN saas_feature_usage f2
    ON  f2.account_id = f1.account_id
    AND f2.feature_name > f1.feature_name
    AND f2.usage_count > 0
WHERE f1.usage_count > 0
GROUP BY f1.feature_name, f2.feature_name
ORDER BY co_usage_accounts DESC;

5. Feature Usage Trend Over Time

SELECT
    DATE_TRUNC('month', period_date)::DATE AS month,
    feature_name,
    SUM(usage_count)                        AS total_uses,
    COUNT(DISTINCT account_id)              AS active_accounts
FROM saas_feature_usage
GROUP BY 1, 2
ORDER BY 1, total_uses DESC;

Key Takeaway

HAVING SUM(usage) = 0 combined with a LEFT JOIN to usage data is the cleanest way to find "accounts with no activity" — the LEFT JOIN preserves accounts with no usage rows, and HAVING filters to those where the sum is zero. NTILE(10) divides accounts into deciles without needing to know the threshold values in advance — it adapts to any data distribution automatically.