FFastero

Connect any database. Ask in plain English.

Try free
Back to blog

Blog article

How to Analyze Support Ticket Trends with SQL

Most support analytics tools track volume and speed. The metric that actually predicts product problems is repeat contact rate. Here are six SQL queries that cover ticket trends, SLA compliance, agent productivity, and the ticket-to-churn correlation most teams never measure.

Fastero Dev TeamFastero Dev Team
2026-08-04
SQLsupport analyticsSLAcustomer successdashboards
How to Analyze Support Ticket Trends with SQL

How to Analyze Support Ticket Trends with SQL

Every support tool ships a "reporting" tab. Zendesk has Explore, Intercom has its built-in reports, Freshdesk has Analytics. They all show you the same three numbers: ticket volume, average response time, average resolution time. And they're all slightly wrong in ways you can't inspect because the queries are hidden behind drag-and-drop widgets.

I spent a year trusting the built-in reports before I pulled the raw data into Postgres and ran the numbers myself. The averages didn't match. Turned out the tool was excluding "merged" tickets from resolution time but including them in volume, which made both numbers misleading in opposite directions. That's not a bug anyone would fix for me. The only fix was writing my own queries.

Here's what I run now. Six queries that cover more ground than most reporting tabs, starting from two tables you can export from any ticketing system or model directly if you're building your own.

The schema

-- tickets: one row per support ticket
-- id, customer_id, subject, category, priority, status,
-- assigned_to, created_at, first_response_at, resolved_at
 
-- ticket_comments: every reply (customer + agent)
-- ticket_id, author_id, is_internal, created_at

That's it. Most ticketing platforms export both. If yours doesn't expose first_response_at as a column, you can derive it from ticket_comments with a min(created_at) WHERE is_internal = false AND author_id != ticket.customer_id subquery. Annoying but straightforward.

1. Volume trends with a 7-day moving average

Raw daily counts are noisy. Mondays spike, weekends drop, holidays create holes. A 7-day moving average smooths that out so you can see whether volume is actually growing or just bouncing around.

WITH daily_volume AS (
  SELECT
    date_trunc('day', created_at)::date AS day,
    count(*)                            AS tickets_created
  FROM tickets
  WHERE created_at >= now() - interval '90 days'
  GROUP BY 1
)
SELECT
  day,
  tickets_created,
  round(avg(tickets_created) OVER (
    ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ), 1) AS moving_avg_7d
FROM daily_volume
ORDER BY day;

If the moving average is climbing while nothing else in the product changed, you probably have a quality regression somewhere. If it's climbing and your user base is growing proportionally, that's fine. The ratio of tickets to active users matters more than the absolute count.

Fastero

Connect your database. Ask questions. Get dashboards.

Postgres, BigQuery, Snowflake, and 10+ sources — live-connected, AI-powered, no dashboard builder learning curve.

Try free →

2. Response and resolution time by priority

Median is the right central tendency here, not average. One ticket that sat open for three weeks because the customer went on vacation will wreck your average but barely touch the median. P95 catches the tail that your worst-experience customers actually live in.

SELECT
  priority,
  count(*)                                                         AS ticket_count,
  round(percentile_cont(0.5) WITHIN GROUP (
    ORDER BY extract(epoch FROM first_response_at - created_at) / 3600
  )::numeric, 1)                                                   AS median_response_hrs,
  round(percentile_cont(0.95) WITHIN GROUP (
    ORDER BY extract(epoch FROM first_response_at - created_at) / 3600
  )::numeric, 1)                                                   AS p95_response_hrs,
  round(percentile_cont(0.5) WITHIN GROUP (
    ORDER BY extract(epoch FROM resolved_at - created_at) / 3600
  )::numeric, 1)                                                   AS median_resolution_hrs,
  round(percentile_cont(0.95) WITHIN GROUP (
    ORDER BY extract(epoch FROM resolved_at - created_at) / 3600
  )::numeric, 1)                                                   AS p95_resolution_hrs
FROM tickets
WHERE resolved_at IS NOT NULL
  AND created_at >= now() - interval '90 days'
GROUP BY priority
ORDER BY
  CASE priority
    WHEN 'critical' THEN 1 WHEN 'high' THEN 2
    WHEN 'medium'   THEN 3 WHEN 'low'  THEN 4
  END;

Watch the gap between median and P95. A 2-hour median with a 48-hour P95 means most tickets are handled fast but a meaningful slice of customers waits two days. That tail is where your one-star reviews come from.

3. Repeat contact rate

This is the metric I think matters most, and it's the one almost nobody tracks.

If 30% of your customers who file a ticket come back with another ticket within 30 days, you don't have a support speed problem. You have a product problem. Those repeat contacts mean either the first resolution didn't stick, or the customer has a different issue rooted in the same underlying friction. Speed won't fix that. Fixing the product will.

WITH customer_tickets AS (
  SELECT
    customer_id,
    count(*)                                         AS ticket_count,
    min(created_at)                                  AS first_ticket,
    max(created_at)                                  AS last_ticket
  FROM tickets
  WHERE created_at >= now() - interval '90 days'
  GROUP BY customer_id
),
repeat_contacts AS (
  SELECT customer_id
  FROM customer_tickets
  WHERE ticket_count >= 2
    AND last_ticket - first_ticket <= interval '30 days'
)
SELECT
  count(DISTINCT ct.customer_id)                     AS total_customers_with_tickets,
  count(DISTINCT rc.customer_id)                     AS repeat_customers_30d,
  round(100.0 * count(DISTINCT rc.customer_id) /
    NULLIF(count(DISTINCT ct.customer_id), 0), 1)    AS repeat_contact_rate_pct
FROM customer_tickets ct
LEFT JOIN repeat_contacts rc ON rc.customer_id = ct.customer_id;

Break this down by category and you'll find that repeat contacts cluster around two or three issue types. Those are your highest-leverage product fixes, and they won't show up in any response-time report.

4. Backlog and SLA compliance

Two questions, one query. The first part tracks open ticket backlog over time (created minus resolved, cumulative). The second calculates SLA compliance per priority tier, assuming your SLAs are defined as maximum resolution hours.

WITH sla_targets(priority, max_resolution_hrs) AS (
  VALUES
    ('critical', 4),
    ('high',     24),
    ('medium',   72),
    ('low',      168)
),
resolved AS (
  SELECT
    t.priority,
    t.resolved_at,
    extract(epoch FROM t.resolved_at - t.created_at) / 3600 AS resolution_hrs,
    s.max_resolution_hrs
  FROM tickets t
  JOIN sla_targets s ON s.priority = t.priority
  WHERE t.resolved_at IS NOT NULL
    AND t.created_at >= now() - interval '90 days'
)
SELECT
  priority,
  count(*)                                                    AS resolved_tickets,
  sum(CASE WHEN resolution_hrs <= max_resolution_hrs THEN 1 ELSE 0 END) AS within_sla,
  round(100.0 * sum(CASE WHEN resolution_hrs <= max_resolution_hrs THEN 1 ELSE 0 END) /
    NULLIF(count(*), 0), 1)                                   AS sla_compliance_pct
FROM resolved
GROUP BY priority
ORDER BY
  CASE priority
    WHEN 'critical' THEN 1 WHEN 'high' THEN 2
    WHEN 'medium'   THEN 3 WHEN 'low'  THEN 4
  END;

SLA compliance above 95% across all tiers usually means your SLAs are too generous. Below 80% on critical tickets means something structural is broken and you need to look at staffing or routing, not just tell agents to type faster.

5. Agent productivity and reopen rate

Tickets resolved per agent per week is the obvious metric. The less obvious one is reopen rate: what percentage of tickets an agent resolves get reopened within 7 days. High throughput with a high reopen rate is worse than moderate throughput with clean resolutions, because every reopen is two tickets worth of work disguised as one.

WITH weekly_resolved AS (
  SELECT
    assigned_to                              AS agent,
    date_trunc('week', resolved_at)::date    AS week,
    count(*)                                 AS resolved_count
  FROM tickets
  WHERE resolved_at IS NOT NULL
    AND created_at >= now() - interval '90 days'
  GROUP BY 1, 2
),
reopens AS (
  SELECT
    t.assigned_to                            AS agent,
    count(*)                                 AS reopen_count
  FROM tickets t
  WHERE t.status = 'open'
    AND t.resolved_at IS NOT NULL
    AND t.created_at >= now() - interval '90 days'
    -- ticket was resolved then reopened
    AND EXISTS (
      SELECT 1 FROM ticket_comments tc
      WHERE tc.ticket_id = t.id
        AND tc.created_at > t.resolved_at
        AND tc.is_internal = false
    )
  GROUP BY 1
),
agent_totals AS (
  SELECT
    agent,
    count(*) AS total_resolved
  FROM tickets
  WHERE resolved_at IS NOT NULL
    AND created_at >= now() - interval '90 days'
  GROUP BY 1
)
SELECT
  wr.agent,
  round(avg(wr.resolved_count), 1)                       AS avg_tickets_per_week,
  at.total_resolved,
  COALESCE(r.reopen_count, 0)                             AS reopens,
  round(100.0 * COALESCE(r.reopen_count, 0) /
    NULLIF(at.total_resolved, 0), 1)                      AS reopen_rate_pct
FROM weekly_resolved wr
JOIN agent_totals at ON at.agent = wr.agent
LEFT JOIN reopens r ON r.agent = wr.agent
GROUP BY wr.agent, at.total_resolved, r.reopen_count
ORDER BY avg_tickets_per_week DESC;

I'd avoid using this as a leaderboard. The agents handling "billing dispute" tickets will always be slower than the agents handling "password reset" tickets, and ranking them side by side without normalizing for category is unfair. Filter by category first, then compare.

6. Tickets and churn: the join nobody runs

Here's the question worth more than the other five combined: do customers who file support tickets churn at a higher rate than customers who don't?

This requires joining your ticket data with subscription data. If both live in the same database, it's a straightforward query. If tickets are in Zendesk and subscriptions are in Stripe, you need to get them into the same place first. (Fastero's cross-source DuckDB store handles this: pull both into DuckDB and join on customer_id or email.)

WITH ticket_counts AS (
  SELECT
    customer_id,
    count(*) AS ticket_count
  FROM tickets
  WHERE created_at >= now() - interval '180 days'
  GROUP BY customer_id
),
customer_status AS (
  SELECT
    s.customer_id,
    CASE WHEN s.canceled_at IS NOT NULL THEN 'churned' ELSE 'active' END AS status
  FROM subscriptions s
  WHERE s.started_at >= now() - interval '365 days'
)
SELECT
  CASE
    WHEN tc.ticket_count IS NULL THEN '0 tickets'
    WHEN tc.ticket_count = 1    THEN '1 ticket'
    WHEN tc.ticket_count <= 3   THEN '2-3 tickets'
    ELSE '4+ tickets'
  END                                                    AS ticket_bucket,
  count(*)                                               AS customers,
  sum(CASE WHEN cs.status = 'churned' THEN 1 ELSE 0 END) AS churned,
  round(100.0 * sum(CASE WHEN cs.status = 'churned' THEN 1 ELSE 0 END) /
    NULLIF(count(*), 0), 1)                              AS churn_rate_pct
FROM customer_status cs
LEFT JOIN ticket_counts tc ON tc.customer_id = cs.customer_id
GROUP BY 1
ORDER BY
  CASE
    WHEN tc.ticket_count IS NULL THEN 0
    WHEN tc.ticket_count = 1    THEN 1
    WHEN tc.ticket_count <= 3   THEN 2
    ELSE 3
  END;

In every dataset I've seen, the pattern is the same: customers with 1 ticket churn at roughly the same rate as customers with 0 tickets (sometimes lower, because contacting support means they still care). But customers with 4+ tickets in a 6-month window churn at 2-3x the base rate. That bucket is your early warning system.

Pair this with revenue data from Stripe and you can prioritize by dollar impact, not just ticket count. A high-ticket-count customer on your enterprise plan is a different problem than a high-ticket-count customer on your free tier. Fastero can join Stripe billing data with your ticket data so you see both dimensions in one view.

What to do with all this

Six queries is a lot to run manually every week. The value isn't in the one-time analysis -- it's in watching these numbers change over time and catching regressions early. A few things that make this practical:

Save these as scheduled reports and put them on a dashboard that refreshes automatically. Repeat contact rate climbing? Flag it before the next planning meeting. SLA compliance dropping for critical tickets? Route it to the team lead before it becomes a customer escalation.

Set up alerts on the metrics that matter most. Repeat contact rate above 25% and SLA compliance below 90% on critical are good starting thresholds. You'll calibrate from there.

The ticket-to-churn join is the one I'd prioritize if you only do one thing from this post. Most teams treat support and retention as separate functions with separate dashboards. They aren't. A customer filing their fourth ticket in two months is telling you they're about to leave, and no CSAT survey will surface that as clearly as the raw data.


Try Fastero free — connect your ticket database and your billing data in one place, then ask questions in plain English or paste the SQL above. No credit card required.

Ready to try it yourself?

Connect your database, ask questions in plain English, and get live dashboards — in under 2 minutes. No credit card required.