# 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;
```