
Customer churn rarely happens “all at once.” In most products, churn is preceded by measurable signals: a steady drop in activity, payment friction, escalating support needs, or stalled onboarding. If you can track customer churn signals from Postgres queries, you can intervene earlier—with targeted outreach, fixes, or in-app nudges—before a cancellation arrives.
This guide shows how to model churn indicators directly in PostgreSQL and how to turn them into reliable, low-noise checks. The focus is practical: SQL patterns you can run on a schedule, thresholds that make sense, and safeguards that keep monitoring safe in production.
What counts as a “churn signal” in Postgres?
A churn signal is any measurable change that correlates with higher cancellation risk. The best signals are:
- Observable in your database (events, logins, invoices, tickets, feature usage)
- Trend-based (drop or spike vs. a baseline)
- Actionable (a human or workflow can respond)
- Low-latency (detected quickly enough to matter)
Common churn signal categories
| Category | Example signal | Typical action |
|---|---|---|
| Engagement | Weekly active days falls 50% vs. prior 4-week average | Customer success outreach, product guidance |
| Onboarding | No “activation” event within 7 days of signup | Trigger onboarding email, offer help |
| Payments | 2+ failed renewals or invoice past due | Notify billing, prompt payment update |
| Support | Ticket volume spikes or sentiment declines | Escalate to engineering/CS |
| Reliability | Error rate increases for a customer’s workspace | Investigate incidents, proactive comms |
Baseline schema: keep it simple
You can adapt the queries below to your own schema. For illustration, assume these tables:
-- accounts: one row per paying organization/customer
-- status might be 'trial', 'active', 'past_due', 'canceled'
accounts(id, created_at, status, plan, mrr_cents)
-- user_events: product analytics events stored in Postgres
user_events(id, account_id, user_id, event_name, occurred_at)
-- invoices: billing system mirrored into Postgres
invoices(id, account_id, due_at, paid_at, status, amount_cents)
-- support_tickets: support system mirrored into Postgres
support_tickets(id, account_id, created_at, resolved_at, priority)
Even if your event stream is in another system, many teams replicate essential facts into Postgres. That’s enough to create durable churn monitoring without adding triggers or application code.
Signal #1: engagement drop vs. trailing baseline
Raw activity counts are noisy. A better approach compares current usage to a customer-specific baseline. The query below flags accounts whose events in the last 7 days are less than 50% of their average weekly events over the previous 4 weeks.
WITH weekly AS (
SELECT
account_id,
date_trunc('week', occurred_at) AS week_start,
count(*) AS events
FROM user_events
WHERE occurred_at >= now() - interval '35 days'
GROUP BY 1, 2
), baseline AS (
SELECT
account_id,
avg(events) FILTER (WHERE week_start < date_trunc('week', now())) AS avg_prev_4w
FROM weekly
WHERE week_start >= date_trunc('week', now()) - interval '28 days'
GROUP BY 1
), current_week AS (
SELECT
account_id,
events AS events_this_week
FROM weekly
WHERE week_start = date_trunc('week', now())
)
SELECT
a.id AS account_id,
coalesce(cw.events_this_week, 0) AS events_this_week,
b.avg_prev_4w,
round( (coalesce(cw.events_this_week, 0) / nullif(b.avg_prev_4w, 0))::numeric, 2) AS ratio
FROM accounts a
JOIN baseline b ON b.account_id = a.id
LEFT JOIN current_week cw ON cw.account_id = a.id
WHERE a.status IN ('trial', 'active')
AND b.avg_prev_4w >= 20
AND coalesce(cw.events_this_week, 0) < 0.5 * b.avg_prev_4w
ORDER BY ratio ASC;
Reduce noise with guardrails
- Minimum baseline volume (e.g.,
avg_prev_4w >= 20) avoids false alarms for tiny accounts. - Segment by plan (SMB vs. enterprise) because usage patterns differ.
- Exclude known seasonal periods for your industry if applicable.
Signal #2: “inactive days” since last meaningful event
Sometimes the best churn indicator is simply that nothing is happening. Track days since an “activation” or “core usage” event.
WITH last_core_event AS (
SELECT
account_id,
max(occurred_at) AS last_seen_at
FROM user_events
WHERE event_name IN ('report_run', 'export_created', 'dashboard_viewed')
GROUP BY 1
)
SELECT
a.id AS account_id,
a.status,
l.last_seen_at,
(now() - l.last_seen_at) AS time_inactive
FROM accounts a
LEFT JOIN last_core_event l ON l.account_id = a.id
WHERE a.status IN ('trial', 'active')
AND (l.last_seen_at IS NULL OR l.last_seen_at < now() - interval '14 days')
ORDER BY l.last_seen_at NULLS FIRST;
For trials, tighten the window (e.g., 3–7 days). For paid accounts, 14–30 days might be more appropriate depending on expected usage cadence.
Signal #3: failed payments and past-due drift
Payment issues are a high-precision churn signal. Flag accounts that are past due or have repeated failed invoice attempts.
WITH recent_invoices AS (
SELECT
account_id,
count(*) FILTER (WHERE status = 'failed') AS failed_count_30d,
max(due_at) FILTER (WHERE status IN ('open','failed')) AS latest_due_at
FROM invoices
WHERE due_at >= now() - interval '30 days'
GROUP BY 1
)
SELECT
a.id AS account_id,
a.mrr_cents,
r.failed_count_30d,
r.latest_due_at
FROM accounts a
JOIN recent_invoices r ON r.account_id = a.id
WHERE a.status IN ('active','past_due')
AND (a.status = 'past_due' OR r.failed_count_30d >= 2)
ORDER BY a.mrr_cents DESC;
To prioritize responses, sort by mrr_cents or customer tier. This helps teams focus on revenue risk first.
Signal #4: support load spike (rate-of-change)
Support volume rising can mean confusion, bugs, or unmet expectations. Compare tickets created in the last week to a baseline.
WITH weekly_tickets AS (
SELECT
account_id,
date_trunc('week', created_at) AS week_start,
count(*) AS tickets
FROM support_tickets
WHERE created_at >= now() - interval '35 days'
GROUP BY 1, 2
), baseline AS (
SELECT
account_id,
avg(tickets) FILTER (WHERE week_start < date_trunc('week', now())) AS avg_prev_4w
FROM weekly_tickets
WHERE week_start >= date_trunc('week', now()) - interval '28 days'
GROUP BY 1
), current_week AS (
SELECT account_id, tickets AS tickets_this_week
FROM weekly_tickets
WHERE week_start = date_trunc('week', now())
)
SELECT
a.id AS account_id,
coalesce(cw.tickets_this_week, 0) AS tickets_this_week,
b.avg_prev_4w
FROM accounts a
JOIN baseline b ON b.account_id = a.id
LEFT JOIN current_week cw ON cw.account_id = a.id
WHERE a.status = 'active'
AND coalesce(cw.tickets_this_week, 0) >= greatest(3, 2 * b.avg_prev_4w)
ORDER BY tickets_this_week DESC;
Churn alerts work best when they highlight a meaningful change from an account’s normal behavior, not just a large absolute number.
Combine signals into a single churn-risk score
Individual checks are useful, but a simple score can reduce alert fatigue by flagging only accounts with multiple risk factors.
WITH signals AS (
SELECT
a.id AS account_id,
-- Example booleans from your own signal queries
(a.status = 'past_due')::int AS s_past_due,
(now() - coalesce(l.last_seen_at, 'epoch'::timestamptz) > interval '14 days')::int AS s_inactive,
(coalesce(e.ratio, 1.0) < 0.5)::int AS s_usage_drop,
(coalesce(t.tickets_spike, false))::int AS s_support_spike
FROM accounts a
LEFT JOIN (
SELECT account_id, max(occurred_at) AS last_seen_at
FROM user_events
WHERE event_name IN ('report_run','export_created','dashboard_viewed')
GROUP BY 1
) l ON l.account_id = a.id
LEFT JOIN (
-- Replace with the engagement drop computation returning (account_id, ratio)
SELECT account_id, min(0.49) AS ratio
FROM accounts
WHERE false
) e ON e.account_id = a.id
LEFT JOIN (
-- Replace with ticket spike computation returning (account_id, tickets_spike)
SELECT account_id, true AS tickets_spike
FROM accounts
WHERE false
) t ON t.account_id = a.id
WHERE a.status IN ('trial','active','past_due')
)
SELECT
account_id,
(s_past_due*3 + s_inactive*2 + s_usage_drop*2 + s_support_spike*1) AS churn_risk_score,
s_past_due, s_inactive, s_usage_drop, s_support_spike
FROM signals
WHERE (s_past_due + s_inactive + s_usage_drop + s_support_spike) >= 2
ORDER BY churn_risk_score DESC;
Weights are product-specific, but a common pattern is to assign the highest weight to payment friction, then inactivity and usage drop, and finally support volatility.
Operational tips: make scheduled checks safe and fast
1) Use read-only access
Churn monitoring should not mutate production data. Create a dedicated Postgres role with minimal permissions:
CREATE ROLE churn_monitor LOGIN PASSWORD '***';
GRANT CONNECT ON DATABASE your_db TO churn_monitor;
GRANT USAGE ON SCHEMA public TO churn_monitor;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO churn_monitor;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO churn_monitor;
If you can, restrict to a replica and require TLS for connections.
2) Index for the access patterns you query
user_events(account_id, occurred_at)user_events(event_name, occurred_at)if filtering by event typesupport_tickets(account_id, created_at)invoices(account_id, due_at, status)
These queries are typically time-windowed and grouped by account_id, so composite indexes matter.
3) Prefer “windowed” checks over full scans
Every example above filters by a recent interval (e.g., 35 days). This makes the query predictable in runtime and cost.
4) Tune thresholds with a backtest
Before you alert humans, backtest for false positives. A quick approach:
- Pick a cohort of accounts that churned and one that didn’t.
- Run the signal queries historically (by replacing
now()with a fixed timestamp). - Measure precision/recall and adjust thresholds (ratio, inactivity days, ticket multiplier).
How to turn Postgres churn queries into alerts (without spamming)
Once the SQL is stable, alerting is mostly about routing and deduplication:
- Route by severity: past-due and high-MRR accounts to paging/push; lower scores to email or a Slack digest.
- Deduplicate by account and time window (e.g., don’t alert more than once per day per account).
- Include context in the alert payload: baseline values, ratios, last activity timestamp, and a link to the account.
If you prefer managing these checks from a phone with scheduled runs and notifications, an iOS tool like Query Alert can execute read-only Postgres queries and notify you when results match your risk conditions.
FAQ: common pitfalls when tracking churn signals in Postgres
“Our events table is huge—will this work?”
Yes, if you constrain time windows, index correctly, and (ideally) query a replica. For very high volume, consider pre-aggregating daily activity per account into a compact table and alert off that.
“What if we have multiple products or workspaces per account?”
Start by scoring at the account level, then drill down to the workspace/product dimension when triaging. You can extend the queries by grouping on (account_id, workspace_id).
“How do we avoid alert fatigue?”
Use multi-signal scoring, minimum baselines, and cooldown windows. Alert only when something changes meaningfully, not just because a threshold is crossed repeatedly.