<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ruth Kegicha</title>
    <description>The latest articles on DEV Community by Ruth Kegicha (@rkeggy).</description>
    <link>https://dev.to/rkeggy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3831166%2F68b84b99-8db2-4a86-879e-d04efd84b0b0.jpg</url>
      <title>DEV Community: Ruth Kegicha</title>
      <link>https://dev.to/rkeggy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rkeggy"/>
    <language>en</language>
    <item>
      <title>Window Functions for Call Centre Analytics — A Practical PostgreSQL Guide</title>
      <dc:creator>Ruth Kegicha</dc:creator>
      <pubDate>Wed, 18 Mar 2026 11:14:24 +0000</pubDate>
      <link>https://dev.to/rkeggy/window-functions-for-call-centre-analytics-a-practical-postgresql-guide-55n7</link>
      <guid>https://dev.to/rkeggy/window-functions-for-call-centre-analytics-a-practical-postgresql-guide-55n7</guid>
      <description>&lt;p&gt;If you've ever tried to calculate agent session durations, track missed call trends across multiple sites, or figure out when an agent went idle — you already know that aggregate functions will only take you so far.&lt;/p&gt;

&lt;p&gt;Window functions changed how I approach call centre analytics entirely. Here's what I've learned building production dashboards on real call data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is a window function?&lt;/strong&gt;&lt;br&gt;
A regular aggregate like &lt;em&gt;SUM()&lt;/em&gt; or &lt;em&gt;COUNT()&lt;/em&gt; collapses rows into one result. A window function performs a calculation across a set of rows related to the current row — without collapsing them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT&lt;br&gt;
  agent_id,&lt;br&gt;
  event_time,&lt;br&gt;
  event_type,&lt;br&gt;
  LAG(event_time) OVER (PARTITION BY agent_id ORDER BY event_time) AS previous_event&lt;br&gt;
FROM agent_activity_log;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That single &lt;em&gt;LAG()&lt;/em&gt; call gives you the previous event timestamp per agent, ordered by time — no subquery, no self-join.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real use case: calculating agent session duration&lt;/strong&gt;&lt;br&gt;
The classic call centre problem — an agent logs in, takes calls, goes on break, logs out. You have a log of events. You need session durations.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT&lt;br&gt;
  agent_id,&lt;br&gt;
  event_time AS login_time,&lt;br&gt;
  LEAD(event_time) OVER (&lt;br&gt;
    PARTITION BY agent_id&lt;br&gt;
    ORDER BY event_time&lt;br&gt;
  ) AS next_event_time,&lt;br&gt;
  LEAD(event_type) OVER (&lt;br&gt;
    PARTITION BY agent_id&lt;br&gt;
    ORDER BY event_time&lt;br&gt;
  ) AS next_event_type&lt;br&gt;
FROM agent_activity_log&lt;br&gt;
WHERE event_type = 'LOGIN';&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Pair &lt;em&gt;LAG()&lt;/em&gt; and &lt;em&gt;LEAD()&lt;/em&gt; together and you can reconstruct full session timelines without ever leaving SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real use case: missed call percentage per site&lt;/strong&gt;&lt;br&gt;
This one trips people up when you have multiple sites with different in-hours rules and company exclusions.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT&lt;br&gt;
  site_id,&lt;br&gt;
  COUNT(*) FILTER (WHERE call_outcome = 'MISSED') AS missed_calls,&lt;br&gt;
  COUNT(*) AS total_calls,&lt;br&gt;
  ROUND(&lt;br&gt;
    COUNT(*) FILTER (WHERE call_outcome = 'MISSED') * 100.0 / NULLIF(COUNT(*), 0),&lt;br&gt;
    2&lt;br&gt;
  ) AS missed_pct,&lt;br&gt;
  AVG(&lt;br&gt;
    COUNT(*) FILTER (WHERE call_outcome = 'MISSED') * 100.0 / NULLIF(COUNT(*), 0)&lt;br&gt;
  ) OVER () AS avg_missed_pct_all_sites&lt;br&gt;
FROM universal_queue&lt;br&gt;
WHERE&lt;br&gt;
  queued_at BETWEEN '2024-01-01' AND '2024-01-31'&lt;br&gt;
  AND in_hours = true&lt;br&gt;
  AND company_id NOT IN (/* exclusions */)&lt;br&gt;
GROUP BY site_id;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Notice &lt;em&gt;NULLIF(COUNT(*), 0)&lt;/em&gt; — always protect against division by zero when calculating percentages on filtered data. And the _AVG(...) OVER () _at the end gives you the cross-site average in the same query without a subquery or CTE.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The three window functions I use most&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ROW_NUMBER()&lt;/em&gt; — deduplicate events, get the latest record per agent:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT * FROM (&lt;br&gt;
  SELECT *,&lt;br&gt;
    ROW_NUMBER() OVER (&lt;br&gt;
      PARTITION BY agent_id ORDER BY event_time DESC&lt;br&gt;
    ) AS rn&lt;br&gt;
  FROM agent_activity_log&lt;br&gt;
) t&lt;br&gt;
WHERE rn = 1;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;_LAG() / LEAD() _— calculate time between events, detect gaps in activity, reconstruct sequences.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;SUM() OVER ()&lt;/em&gt; — running totals across a result set without grouping away your detail rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One thing that catches people out&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PARTITION BY&lt;/strong&gt; is not the same as &lt;strong&gt;GROUP BY&lt;/strong&gt;. Group by collapses your rows. Partition by keeps them — the window function just sees a subset of rows for its calculation. You can mix aggregates and window functions in the same query but they run at different stages. If you're getting unexpected results, check whether your &lt;strong&gt;WHERE&lt;/strong&gt; clause is filtering rows before or after the window function runs — it usually is.&lt;/p&gt;

&lt;p&gt;Where to go next&lt;/p&gt;

&lt;p&gt;If this is new to you, practice with &lt;strong&gt;ROW_NUMBER()&lt;/strong&gt; first — it's the most intuitive and the errors are easy to read. Then move to **LAG()/LEAD() **for anything involving sequences of events. Once those feel natural, &lt;em&gt;RANK()&lt;/em&gt;, &lt;em&gt;DENSE_RANK()&lt;/em&gt; and &lt;em&gt;NTILE()&lt;/em&gt; are just variations on the same idea.&lt;/p&gt;

&lt;p&gt;Window functions are one of those SQL features that once you know, you can't imagine working without. Especially when the data is telling a story across time.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>beginners</category>
      <category>analytics</category>
    </item>
  </channel>
</rss>
