Count max. number of concurrent user sessions per day

I would serialize logins and logouts with UNION ALL, "in" counts as 1, "out" counts as -1. Then compute a running count with a simple window function and get the max per day.

Since it has not been specified, assuming that:

  • "Concurrent" means at the same point in time (not just on the same day).
  • Sessions can span any range of time (i.e. multiple days, too).
  • Each user can only be online once at one point in time. So no grouping per user is needed in my solution.
  • Logout trumps login. If both occur at the same time, logout is counted first (leading to a lower concurrent number in corner cases).
WITH range AS (SELECT '2014-03-01'::date AS start_date  -- time range
                    , '2014-03-31'::date AS end_date)   -- inclusive bounds
, cte AS (
   SELECT *
   FROM   tbl, range r
   WHERE  login_date  <= r.end_date
   AND    logout_date >= r.start_date
   )
, ct AS (
   SELECT log_date, sum(ct) OVER (ORDER BY log_date, log_time, ct) AS session_ct
   FROM  (
      SELECT logout_date AS log_date, logout_time AS log_time, -1 AS ct FROM cte
      UNION ALL
      SELECT login_date, login_time, 1 FROM cte
      ) sub
   )
SELECT log_date, max(session_ct) AS max_sessions
FROM   ct, range r
WHERE  log_date BETWEEN r.start_date AND r.end_date  -- crop actual time range
GROUP  BY 1
ORDER  BY 1;

You might use the OVERLAPS operator in cte:

AND   (login_date, logout_date) OVERLAPS (r.start_date, r.end_date)

Details:

  • Find overlapping date ranges in PostgreSQL

But that might not be a good idea because (per documentation):

Each time period is considered to represent the half-open interval start <= time < end, unless start and end are equal in which case it represents that single time instant. This means for instance that two time periods with only an endpoint in common do not overlap.

Bold emphasis mine. The upper bound of your range would have to be the day after your desired time frame.

Explain

  • CTE are available since Postgres 8.4.

  • The 1st CTE range is just for convenience of providing the time range once.

  • The 2nd CTE cte selects only relevant rows: those that ...

    • start before or in the range
    • and end in or after the range
  • The 3rd CTE ct serializes "in" and "out" points with values of +/-1 and computes a running count with the aggregate function sum() used as window function. Those are available since Postgres 8.4.

  • In the final SELECT trim leading and trailing days and aggregate the maximum per day. Voilá.

SQL Fiddle for Postgres 9.6.
Postgres 8.4 is too old and not available any more, but should work the same. I added a rows to the test case - one spanning multiple days. Should make it more useful.

Notes

I would generally use timestamp instead of date and time. Same size, easier to handle. Or timestamptz if multiple time zones can be involved.

An index on (login_date, logout_date DESC) is instrumental for performance as a bare minimum.