---
# System prepended metadata

title: Redshift Workgroup Usage Analysis

---

# Redshift Workgroup Usage Analysis
## Summary
This document provides a comprehensive set of SQL queries for analyzing Amazon Redshift workgroup usage patterns and performance metrics using the SYS_QUERY_DETAIL and SYS_SERVERLESS_USAGE system tables. The analysis is divided into five main components:

1. User Usage Analysis: Tracks individual user activity with configurable usage tiers based on query duration, data processing, and resource consumption metrics.

2. Temporal Usage Patterns: Examines usage distribution across different time periods (daily and hourly) to identify peak usage times and usage patterns.

3. Query Performance Analysis: Identifies longest running queries and resource-intensive operations to highlight optimization opportunities.

4. Compute Usage Analysis: Analyzes RPU (Redshift Processing Units) consumption, storage usage, and correlates it with user activity and query patterns.

5. Performance Optimization: Provides detailed insights into step-level performance, table access patterns, and query complexity to help identify bottlenecks and optimization candidates.

Each section includes configurable thresholds and detailed metrics to support workload management and performance tuning decisions.

Reference table documentation: [SYS_QUERY_DETAIL](https://docs.aws.amazon.com/redshift/latest/dg/SYS_QUERY_DETAIL.html), [SYS_SERVERLESS_USAGE](https://docs.aws.amazon.com/redshift/latest/dg/SYS_SERVERLESS_USAGE.html).

## 1. User usage statistics
Sets configurable thresholds in the parameters CTE.

Categorizes users into usage tiers based on their total query duration.

Orders results by total duration to easily identify heavy users. 

Calculates per-user statistics for the current month:
* Total number of queries
* Total and average duration
* Total data processed (in MB)
* Total rows produced
* Total blocks read
* Total blocks spilled (both local and remote)

```
WITH parameters AS ( -- Define usage thresholds (in minutes)
  SELECT 
    60 as low_threshold,      -- 1 hour
    180 as medium_low_threshold,  -- 3 hours
    360 as medium_threshold,      -- 6 hours
    720 as medium_high_threshold  -- 12 hours
),
user_stats AS (
  SELECT 
    user_id,
    COUNT(DISTINCT query_id) as total_queries,
    SUM(duration)/1000000.0 as total_duration_seconds,
    AVG(duration)/1000000.0 as avg_duration_seconds,
    SUM(input_bytes)/1024/1024.0 as total_mb_processed,
    SUM(output_rows) as total_rows_produced,
    SUM(blocks_read) as total_blocks_read,
    SUM(spilled_block_local_disk + spilled_block_remote_disk) as total_blocks_spilled
  FROM sys_query_detail
  GROUP BY user_id
)
SELECT 
    us.user_id,
    us.total_queries,
    ROUND(us.total_duration_seconds/60.0, 2) as total_duration_minutes,
    ROUND(us.avg_duration_seconds, 2) as avg_duration_seconds,
    ROUND(us.total_mb_processed, 2) as total_mb_processed,
    us.total_rows_produced,
    us.total_blocks_read,
    us.total_blocks_spilled,
    CASE 
        WHEN us.total_duration_seconds/60.0 <= p.low_threshold THEN 'low'
        WHEN us.total_duration_seconds/60.0 <= p.medium_low_threshold THEN 'medium low'
        WHEN us.total_duration_seconds/60.0 <= p.medium_threshold THEN 'medium'
        WHEN us.total_duration_seconds/60.0 <= p.medium_high_threshold THEN 'medium high'
        ELSE 'high'
    END as usage_tier
FROM user_stats us
CROSS JOIN parameters p
ORDER BY total_duration_seconds DESC;
```

## 2. Daily and hourly usage statistics
Sets configurable thresholds in the parameters CTE.
Categorizes usage into tiers based on the total amount of queries.

### 2.1 Daily usage
Calculates per-day statistics for the current month:
* Daily usage patterns with date and day of week.
* Total queries, duration, and data processed per day
* Number of distinct users per day

```
WITH parameters AS (
  SELECT 
    1000 as low_threshold,      -- Adjust these thresholds based on your workload
    5000 as medium_low_threshold,
    10000 as medium_threshold,
    20000 as medium_high_threshold
),
daily_stats AS (
  SELECT 
    DATE_TRUNC('day', start_time) as usage_date,
    EXTRACT(DOW FROM start_time) as day_of_week,
    COUNT(DISTINCT query_id) as total_queries,
    SUM(duration)/1000000.0 as total_duration_seconds,
    SUM(input_bytes)/1024/1024.0 as total_mb_processed,
    SUM(output_rows) as total_rows_produced,
    SUM(blocks_read) as total_blocks_read,
    COUNT(DISTINCT user_id) as distinct_users
  FROM sys_query_detail
  GROUP BY DATE_TRUNC('day', start_time), EXTRACT(DOW FROM start_time)
)
SELECT 
    usage_date,
    CASE day_of_week
        WHEN 0 THEN 'Sunday'
        WHEN 1 THEN 'Monday'
        WHEN 2 THEN 'Tuesday'
        WHEN 3 THEN 'Wednesday'
        WHEN 4 THEN 'Thursday'
        WHEN 5 THEN 'Friday'
        WHEN 6 THEN 'Saturday'
    END as day_name,
    total_queries,
    ROUND(total_duration_seconds/60.0, 2) as total_duration_minutes,
    ROUND(total_mb_processed, 2) as total_mb_processed,
    total_rows_produced,
    distinct_users,
    CASE 
        WHEN total_queries <= p.low_threshold THEN 'low'
        WHEN total_queries <= p.medium_low_threshold THEN 'medium low'
        WHEN total_queries <= p.medium_threshold THEN 'medium'
        WHEN total_queries <= p.medium_high_threshold THEN 'medium high'
        ELSE 'high'
    END as usage_tier
FROM daily_stats
CROSS JOIN parameters p
ORDER BY usage_date;
```
### 2.2 Hourly usage

Calculates per-hour statistics for the current month:
* Hour-by-hour usage patterns
* Query volume distribution for each hour
* Number of distinct users per hour

```
WITH hourly_stats AS (
  SELECT 
    EXTRACT(HOUR FROM start_time) as hour_of_day,
    COUNT(DISTINCT query_id) as total_queries,
    SUM(duration)/1000000.0 as total_duration_seconds,
    SUM(input_bytes)/1024/1024.0 as total_mb_processed,
    SUM(output_rows) as total_rows_produced,
    COUNT(DISTINCT user_id) as distinct_users,
    AVG(duration)/1000000.0 as avg_query_duration_seconds
  FROM sys_query_detail
  GROUP BY EXTRACT(HOUR FROM start_time)
)
SELECT 
    hour_of_day,
    LPAD(hour_of_day::text || ':00', 5, '0') as hour_range,
    total_queries,
    ROUND(total_duration_seconds/60.0, 2) as total_duration_minutes,
    ROUND(total_mb_processed, 2) as total_mb_processed,
    total_rows_produced,
    distinct_users,
    ROUND(avg_query_duration_seconds, 2) as avg_query_duration_seconds
FROM hourly_stats
ORDER BY hour_of_day;
```

## 3. Query statistics

Sets configurable thresholds in the parameters CTE.
Categorizes query consumption into tiers based on the duration of queries and resource usage during the month.

### 3.1 Longest-running queries
Calculates the longest-running  queries based on the thresholds.
* Duration time in seconds and minutes
* User ID, and start and end time
* Data processing metrics
```
WITH parameters AS (
  SELECT 
    60 as low_duration_threshold,      -- 1 minute
    300 as medium_low_duration_threshold,  -- 5 minutes
    900 as medium_duration_threshold,      -- 15 minutes
    1800 as medium_high_duration_threshold -- 30 minutes
)
SELECT 
    query_id,
    ROUND(duration/1000000.0, 2) as duration_seconds,
    ROUND(duration/1000000.0/60, 2) as duration_minutes,
    user_id,
    start_time,
    end_time,
    ROUND(input_bytes/1024/1024.0, 2) as input_mb,
    output_rows,
    CASE 
        WHEN duration/1000000.0 <= p.low_duration_threshold THEN 'low'
        WHEN duration/1000000.0 <= p.medium_low_duration_threshold THEN 'medium low'
        WHEN duration/1000000.0 <= p.medium_duration_threshold THEN 'medium'
        WHEN duration/1000000.0 <= p.medium_high_duration_threshold THEN 'medium high'
        ELSE 'high'
    END as duration_tier
FROM sys_query_detail
CROSS JOIN parameters p
ORDER BY duration DESC
LIMIT 100;
```
### 3.2 Data consumption statistics
Identifies data-intensive queries
* Detailed resource metrics (IO, spill, skewness)
* Processing volume metrics
* Performance indicators like data and time skewness
```
WITH parameters AS (
  SELECT 
    1000 as low_mb_threshold,      -- Adjust MB processed thresholds
    5000 as medium_low_mb_threshold,
    10000 as medium_mb_threshold,
    50000 as medium_high_mb_threshold
),
query_resources AS (
  SELECT 
    query_id,
    COUNT(*) as execution_count,
    SUM(duration)/1000000.0 as total_duration_seconds,
    AVG(duration)/1000000.0 as avg_duration_seconds,
    SUM(input_bytes)/1024/1024.0 as total_mb_processed,
    SUM(output_rows) as total_rows_produced,
    SUM(blocks_read) as total_blocks_read,
    SUM(local_read_IO) as total_local_io,
    SUM(remote_read_IO) as total_remote_io,
    SUM(spilled_block_local_disk) as local_spill,
    SUM(spilled_block_remote_disk) as remote_spill,
    MAX(data_skewness) as max_data_skewness,
    MAX(time_skewness) as max_time_skewness
  FROM sys_query_detail
  GROUP BY query_id
)
SELECT 
    query_id,
    execution_count,
    ROUND(total_duration_seconds/60.0, 2) as total_duration_minutes,
    ROUND(avg_duration_seconds, 2) as avg_duration_seconds,
    ROUND(total_mb_processed, 2) as total_mb_processed,
    total_rows_produced,
    total_blocks_read,
    total_local_io,
    total_remote_io,
    local_spill + remote_spill as total_spill,
    max_data_skewness,
    max_time_skewness,
    CASE 
        WHEN total_mb_processed <= p.low_mb_threshold THEN 'low'
        WHEN total_mb_processed <= p.medium_low_mb_threshold THEN 'medium low'
        WHEN total_mb_processed <= p.medium_mb_threshold THEN 'medium'
        WHEN total_mb_processed <= p.medium_high_mb_threshold THEN 'medium high'
        ELSE 'high'
    END as resource_consumption_tier
FROM query_resources
CROSS JOIN parameters p
ORDER BY total_mb_processed DESC
LIMIT 100;
```
## 4 Compute usage analysis
Identify peak compute usage and correlate it with users and query usage statistics.
Includes charged hours from billing metrics, average storage used. Distincs users per hour.
### 4.1 RPU and storage usage statistics
```
WITH hourly_compute AS (
  SELECT 
    DATE_TRUNC('hour', start_time) as usage_hour,
    AVG(compute_capacity) as avg_rpus,
    MAX(compute_capacity) as peak_rpus,
    SUM(compute_seconds)/3600.0 as compute_hours,
    SUM(charged_seconds)/3600.0 as charged_hours,
    AVG(data_storage)/1024.0 as avg_storage_gb
  FROM sys_serverless_usage
  GROUP BY DATE_TRUNC('hour', start_time)
),
user_query_stats AS (
  SELECT 
    DATE_TRUNC('hour', start_time) as usage_hour,
    user_id,
    COUNT(DISTINCT query_id) as user_queries,
    SUM(duration)/1000000.0 as user_duration_seconds,
    SUM(input_bytes)/1024/1024.0 as user_mb_processed,
    SUM(output_rows) as user_rows_produced,
    SUM(blocks_read) as user_blocks_read,
    SUM(spilled_block_local_disk + spilled_block_remote_disk) as user_blocks_spilled
  FROM sys_query_detail
  GROUP BY DATE_TRUNC('hour', start_time), user_id
),
hourly_stats AS (
  SELECT 
    usage_hour,
    COUNT(DISTINCT user_id) as distinct_users,
    SUM(user_queries) as total_queries,
    AVG(user_queries) as avg_queries_per_user,
    MAX(user_queries) as max_queries_per_user,
    SUM(user_duration_seconds) as total_duration_seconds,
    AVG(user_duration_seconds) as avg_duration_per_user,
    SUM(user_mb_processed) as total_mb_processed,
    SUM(user_rows_produced) as total_rows_produced,
    SUM(user_blocks_read) as total_blocks_read,
    SUM(user_blocks_spilled) as total_blocks_spilled
  FROM user_query_stats
  GROUP BY usage_hour
)
SELECT 
    hc.usage_hour,
    EXTRACT(DOW FROM hc.usage_hour) as day_of_week,
    EXTRACT(HOUR FROM hc.usage_hour) as hour_of_day,
    -- Compute capacity metrics
    ROUND(hc.avg_rpus, 2) as avg_rpus,
    ROUND(hc.peak_rpus, 2) as peak_rpus,
    ROUND(hc.compute_hours, 2) as compute_hours,
    ROUND(hc.charged_hours, 2) as charged_hours,
    ROUND(hc.avg_storage_gb, 2) as avg_storage_gb,
    -- User activity metrics
    hs.distinct_users,
    hs.total_queries,
    ROUND(hs.avg_queries_per_user, 1) as avg_queries_per_user,
    hs.max_queries_per_user,
    -- Workload metrics
    ROUND(hs.total_duration_seconds/60.0, 2) as total_duration_minutes,
    ROUND(hs.avg_duration_per_user/60.0, 2) as avg_minutes_per_user,
    ROUND(hs.total_mb_processed, 2) as total_mb_processed,
    hs.total_rows_produced,
    hs.total_blocks_read,
    hs.total_blocks_spilled
FROM hourly_compute hc
LEFT JOIN hourly_stats hs ON hc.usage_hour = hs.usage_hour
ORDER BY hc.peak_rpus DESC, hc.avg_rpus DESC;
```
## 5. Performance improvement analysis
Identify possible bottlenecks, possible queries optimization candidates, and table optimization
### 5.1 Step-level performance statistics
Step-level performance metrics to identify bottlenecks in query execution
```
WITH step_stats AS (
  SELECT 
    step_name,
    COUNT(*) as occurrence_count,
    AVG(duration)/1000000.0 as avg_duration_seconds,
    SUM(input_bytes)/1024/1024.0 as total_mb_processed,
    AVG(data_skewness) as avg_data_skewness,
    AVG(time_skewness) as avg_time_skewness
  FROM sys_query_detail
  GROUP BY step_name
)
SELECT 
    step_name,
    occurrence_count,
    ROUND(avg_duration_seconds, 2) as avg_duration_seconds,
    ROUND(total_mb_processed, 2) as total_mb_processed,
    ROUND(avg_data_skewness, 2) as avg_data_skewness,
    ROUND(avg_time_skewness, 2) as avg_time_skewness
FROM step_stats
ORDER BY avg_duration_seconds DESC;
```
### 5.2 Table access patterns statistics
Table access patterns to optimize frequently accessed tables
```
SELECT 
    table_name,
    COUNT(DISTINCT query_id) as query_count,
    COUNT(DISTINCT user_id) as user_count,
    SUM(input_bytes)/1024/1024.0 as total_mb_read,
    SUM(blocks_read) as total_blocks_read,
    AVG(duration)/1000000.0 as avg_step_duration_seconds,
    MAX(is_rrscan) as uses_range_restricted_scan
FROM sys_query_detail
GROUP BY table_name
ORDER BY query_count DESC;
```
### 5.3 Query complexity analysis
Query complexity metrics to identify candidates for optimization
```
WITH parameters AS (
    SELECT 
        50 as simple_threshold,      -- Queries with 5 or fewer steps
        100 as moderate_threshold,   -- Queries with 6-15 steps
        200 as complex_threshold     -- Queries with 16-30 steps
                                    -- More than 30 steps = Very Complex
),
query_steps AS (
    SELECT 
        query_id,
        COUNT(CASE WHEN step_id >= 0 THEN 1 END) as total_steps,
        COUNT(DISTINCT step_name) as distinct_step_types,
        SUM(duration)/1000000.0 as total_duration_seconds,
        SUM(input_bytes)/1024/1024.0 as total_mb_processed,
        SUM(output_rows) as total_rows_produced,
        MAX(data_skewness) as max_data_skewness,
        MAX(time_skewness) as max_time_skewness
    FROM sys_query_detail
    GROUP BY query_id
)
SELECT 
    query_id,
    total_steps,
    distinct_step_types,
    ROUND(total_duration_seconds, 2) as duration_seconds,
    ROUND(total_mb_processed, 2) as mb_processed,
    total_rows_produced,
    ROUND(max_data_skewness, 2) as max_data_skewness,
    ROUND(max_time_skewness, 2) as max_time_skewness,
    CASE 
        WHEN total_steps <= p.simple_threshold THEN 'Simple'
        WHEN total_steps <= p.moderate_threshold THEN 'Moderate'
        WHEN total_steps <= p.complex_threshold THEN 'Complex'
        ELSE 'Very Complex'
    END as complexity_category
FROM query_steps
CROSS JOIN parameters p
ORDER BY total_steps DESC;
```
## 6. Workload categorization query
```
WITH scan_sum AS
(
  SELECT query_id,
         segment_id,
         SUM(output_bytes) AS bytes,
         ROUND(datediff (ms,MIN(start_time),MAX(end_time)) / 1000.0,3) AS seg_sec
  FROM sys_query_detail
  WHERE user_id > 1
  AND step_name='scan'
  AND   start_time > '2000-01-01 00:00:00'
  AND   end_time > '2000-01-01 00:00:00'
  GROUP BY query_id,
           segment_id
),
scan_list AS
(
  SELECT query_id,
         MAX(bytes) AS max_scan_bytes,
         MAX(CASE WHEN seg_sec > 0 THEN seg_sec ELSE 0 END) AS seg_sec_max
  FROM scan_sum
  GROUP BY query_id
) ,
query_list AS
(
  SELECT w.query_id,
         start_time,
         end_time,
         ROUND(execution_time / 1000 / 1000.0,3) AS exec_sec,
         max_scan_bytes,
         seg_sec_max,
         CASE
           WHEN max_scan_bytes <     1000000000 THEN 'xx-small-8RPU'
           WHEN max_scan_bytes <    10000000000 THEN 'x-small-32RPU'
           WHEN max_scan_bytes <   100000000000 THEN 'small-64RPU'
           WHEN max_scan_bytes <   500000000000 THEN 'medium-128RPU'
           WHEN max_scan_bytes <  1000000000000 THEN 'large-256RPU'
           WHEN max_scan_bytes <  3000000000000 THEN 'x-large-512RPU'
           WHEN max_scan_bytes >  3000000000000 THEN 'xx-large-1024RPU'
           else 'N/A'
         END AS size_type
  FROM sys_query_history w,
       scan_list sc
  WHERE sc.query_id = w.query_id
)
SELECT size_type,
       COUNT(*) AS query_cnt,
       AVG(exec_sec) AS exec_sec_avg,
       MAX(exec_sec) AS exec_sec_max,
       MIN(exec_sec) AS exec_sec_min,
       AVG(seg_sec_max) AS seg_sec_max_avg,
       AVG(max_scan_bytes) AS max_scan_bytes_avg,
       COUNT(*) * AVG(exec_sec) AS weightage
FROM query_list
GROUP BY 1
ORDER BY max_scan_bytes_avg
;
```
## 7. Cost estimation query
```
WITH recursive numbers(rown) as (
    select 1
    UNION ALL
    select rown + 1 from numbers where rown < 10080
),
epochval as (
    select extract(epoch from date_trunc('day',current_timestamp)) - rown*60 ep from numbers
),
t_minute as (
    select TIMESTAMP 'epoch' + (cast(ep as bigint)) *INTERVAL '1 second' minutes_week 
    from epochval order by minutes_week
),
scan_sum AS (
    SELECT query_id,
           segment_id,
           SUM(output_bytes) AS bytes,
           ROUND(datediff (ms,MIN(start_time),MAX(end_time)) / 1000.0,3) AS seg_sec
    FROM sys_query_detail
    WHERE user_id > 1
    AND step_name='scan'
    AND start_time > '2000-01-01 00:00:00'
    AND end_time > '2000-01-01 00:00:00'
    GROUP BY query_id, segment_id
),
scan_list AS (
    SELECT query_id,
           MAX(bytes) AS max_scan_bytes,
           MAX(CASE WHEN seg_sec > 0 THEN seg_sec ELSE 0 END) AS seg_sec_max
    FROM scan_sum
    GROUP BY query_id
),
query_list AS (
    SELECT max_scan_bytes,
           ROUND(execution_time / 1000 / 1000.0,3) AS exec_sec,
           CASE
               WHEN max_scan_bytes < 1000000000 THEN '8'
               WHEN max_scan_bytes < 10000000000 THEN '32'
               WHEN max_scan_bytes < 100000000000 THEN '64'
               WHEN max_scan_bytes < 500000000000 THEN '128'
               WHEN max_scan_bytes < 1000000000000 THEN '256'
               WHEN max_scan_bytes < 3000000000000 THEN '512'
               WHEN max_scan_bytes > 3000000000000 THEN '1024'
               else 'N/A'
           END AS recommended_RPU_by_workload
    FROM sys_query_history w,
         scan_list sc
    WHERE sc.query_id = w.query_id
),
workload_weightage AS (
    SELECT recommended_RPU_by_workload,
           COUNT(*) * AVG(exec_sec) weightage
    FROM query_list
    GROUP BY 1
),
dominant_workload AS (
    SELECT recommended_RPU_by_workload
    FROM workload_weightage
    where weightage = (select max(weightage) from workload_weightage)
)

SELECT distinct trunc(minutes_week) data_day,
       (select recommended_RPU_by_workload from dominant_workload) as recommended_base_RPU,
       ceiling(1.0*count(distinct minutes_week)) As estimated_serverless_minutes_per_day,
       concat(round(100.0*count(distinct minutes_week)/1440,2),'%') As estimated_serverless_usage_percentage,
       round(0.375*24*(select recommended_RPU_by_workload from dominant_workload)*count(distinct minutes_week)/1440,2) As estimated_serverless_daily_cost
FROM t_minute
GROUP BY trunc(minutes_week)
ORDER BY 1;
```