First contact resolution (FCR)¶
How often do we solve the problem in a single interaction?
Current value (service case data): 62.5% - ! Watch. Open the case.
| Field | Value |
|---|---|
| Definition | Resolved tickets that needed exactly one customer contact and were not reopened, divided by all resolved tickets. |
| Formula | count(contacts = 1 AND NOT reopened) / count(resolved) |
| Unit | % |
| Granularity | Ticket; reported monthly by channel and queue. |
| Direction | Higher is better ↑ |
| Target and thresholds | ≥ 70.0% (amber ≥ 60.0%) |
| Parameters | – |
| Owner | Service Desk Manager |
| Source | Ticketing system (synthetic generator) |
| Input | table tickets, columns status, contacts, reopened |
| Recommended chart | bar - Comparison across channels, sorted, against the target line. |
| Related KPIs | Reopen rate, Resolution time (median) |
How to read it¶
Effort saved for the customer and the team. Compare by channel: chat and phone usually differ structurally, so set targets per channel.
Common misreadings¶
- Counting a ticket as FCR and then letting it reopen double-counts success; reopened tickets are excluded here.
- Pushing FCR by closing tickets prematurely shows up as a higher reopen rate - read them together.
Worked example (from the test suite)¶
| ticket_id | status | first_response_hours | resolution_hours | sla_target_hours | contacts | reopened | age_days |
|---|---|---|---|---|---|---|---|
| 1 | resolved | 1.0 | 10.0 | 24.0 | 1 | False | – |
| 2 | resolved | 3.0 | 30.0 | 24.0 | 2 | False | – |
| 3 | resolved | 0.5 | 4.0 | 8.0 | 1 | True | – |
| 4 | resolved | 6.0 | 50.0 | 72.0 | 1 | False | – |
| 5 | open | – | – | 24.0 | 1 | False | 9.0 |
Expected result: 50.00%. Resolved with 1 contact and not reopened: #1 yes, #2 no (2 contacts), #3 no (reopened), #4 yes. 2 / 4 = 0.5.
This example is asserted in tests/test_formulas.py against the Python function and in tests/test_sql.py against the SQL below.
Implementations¶
Python function (the single implementation used by the site):
@kpi("first_contact_resolution")
def first_contact_resolution(df: pd.DataFrame) -> float:
r = _resolved(df)
return safe_ratio((r["contacts"].eq(1) & ~r["reopened"].astype(bool)).sum(), len(r))
Reference SQL (tested on SQLite):
SELECT CAST(SUM(CASE WHEN contacts = 1 AND reopened = 0 THEN 1 ELSE 0 END) AS REAL) / COUNT(*)
FROM tickets WHERE status = 'resolved'