Advanced Services Queries
This guide provides sophisticated SQL queries for deeper analysis of your Planning Center Services data. These queries help identify patterns, optimize scheduling, and improve ministry effectiveness.SQL Proficiency Recommended
These queries use advanced SQL features like CTEs, window functions, and complex joins. Familiarity with SQL will help you customize them for your specific needs.
Query Requirements
Schema Prefix
IMPORTANT: All tables in the Planning Center Services module live in theplanning_center
schema. Always prefix table names with planning_center.
in advanced queries.
✅ CORRECT: SELECT * FROM planning_center.services_plan_people
❌ INCORRECT: SELECT * FROM services_plan_people
Row Level Security (RLS)
Row Level Security automatically manages:- tenant_organization_id – isolates results to your organization
- system_status – active records returned by default
- ❌
WHERE tenant_organization_id = 1
- ❌
WHERE system_status = 'active'
Volunteer Analytics
Volunteer Burnout Detection
Copy
-- Identify volunteers who may be overserving
WITH volunteer_stats AS (
SELECT
p.person_id,
p.full_name,
COUNT(DISTINCT pl.plan_id) as services_scheduled,
COUNT(DISTINCT DATE_TRUNC('week', pl.sort_date)) as weeks_served,
COUNT(DISTINCT t.team_id) as teams_serving_on,
COUNT(CASE WHEN pp.status = 'D' THEN 1 END) as times_declined,
MAX(pl.sort_date) as last_scheduled
FROM planning_center.services_people p
JOIN planning_center.services_plan_people pp ON p.person_id = pp.person_id
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
JOIN planning_center.services_teams t ON pp.team_id = t.team_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '2 months'
AND pl.sort_date <= CURRENT_DATE + INTERVAL '1 month'
GROUP BY p.person_id, p.full_name
),
burnout_indicators AS (
SELECT
person_id,
full_name,
services_scheduled,
weeks_served,
teams_serving_on,
times_declined,
last_scheduled,
ROUND(services_scheduled::numeric / NULLIF(weeks_served, 0), 2) as avg_per_week,
ROUND(times_declined::numeric * 100 / NULLIF(services_scheduled, 0), 1) as decline_rate
FROM volunteer_stats
)
SELECT
full_name,
services_scheduled,
weeks_served,
teams_serving_on,
avg_per_week,
decline_rate as decline_percentage,
CASE
WHEN avg_per_week > 3 THEN 'High Risk'
WHEN avg_per_week > 2 OR decline_rate > 30 THEN 'Medium Risk'
WHEN avg_per_week > 1.5 OR decline_rate > 20 THEN 'Low Risk'
ELSE 'Healthy'
END as burnout_risk,
last_scheduled
FROM burnout_indicators
WHERE services_scheduled >= 4 -- Only show active volunteers
ORDER BY avg_per_week DESC, decline_rate DESC;
Team Health Score
Copy
-- Comprehensive team health analysis
WITH team_metrics AS (
SELECT
t.team_id,
t.name as team_name,
COUNT(DISTINCT pp.person_id) as active_members,
COUNT(DISTINCT pp.plan_id) as total_schedules,
AVG(CASE WHEN pp.status = 'C' THEN 1.0 ELSE 0 END) * 100 as confirm_rate,
COUNT(DISTINCT np.plan_id) as plans_with_needs
FROM planning_center.services_teams t
LEFT JOIN planning_center.services_plan_people pp ON t.team_id = pp.team_id
LEFT JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
LEFT JOIN planning_center.services_needed_positions np ON t.team_id = np.team_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
AND t.archived_at IS NULL
GROUP BY t.team_id, t.name
),
position_coverage AS (
SELECT
t.team_id,
COUNT(DISTINCT tp.team_position_id) as total_positions,
COUNT(DISTINCT pa.person_id) as qualified_people
FROM planning_center.services_teams t
JOIN planning_center.services_team_positions tp ON t.team_id = tp.team_id
LEFT JOIN planning_center.services_person_team_position_assignments pa
ON tp.team_position_id = pa.team_position_id
GROUP BY t.team_id
)
SELECT
tm.team_name,
tm.active_members,
pc.total_positions,
pc.qualified_people,
ROUND(pc.qualified_people::numeric / NULLIF(pc.total_positions, 0), 2) as people_per_position,
ROUND(tm.confirm_rate, 1) as confirm_percentage,
tm.plans_with_needs as unfilled_schedules,
CASE
WHEN tm.confirm_rate >= 90 AND pc.qualified_people >= pc.total_positions * 2 THEN 'Excellent'
WHEN tm.confirm_rate >= 80 AND pc.qualified_people >= pc.total_positions * 1.5 THEN 'Good'
WHEN tm.confirm_rate >= 70 AND pc.qualified_people >= pc.total_positions THEN 'Fair'
ELSE 'Needs Attention'
END as team_health
FROM team_metrics tm
JOIN position_coverage pc ON tm.team_id = pc.team_id
WHERE tm.active_members > 0
ORDER BY tm.confirm_rate DESC;
Scheduling Patterns Analysis
Copy
-- Analyze when people prefer to serve
WITH scheduling_patterns AS (
SELECT
p.person_id,
p.full_name,
pt.name as time_name,
EXTRACT(HOUR FROM pt.starts_at) as service_hour,
COUNT(*) as times_scheduled,
COUNT(CASE WHEN pp.status = 'C' THEN 1 END) as times_confirmed,
COUNT(CASE WHEN pp.status = 'D' THEN 1 END) as times_declined
FROM planning_center.services_people p
JOIN planning_center.services_plan_people pp ON p.person_id = pp.person_id
JOIN planning_center.services_plan_person_times ppt ON pp.plan_person_id = ppt.plan_person_id
JOIN planning_center.services_plan_times pt ON ppt.plan_time_id = pt.plan_time_id
WHERE pt.starts_at IS NOT NULL
GROUP BY p.person_id, p.full_name, pt.name, service_hour
)
SELECT
full_name,
time_name,
service_hour,
times_scheduled,
times_confirmed,
times_declined,
ROUND(times_confirmed::numeric * 100 / NULLIF(times_scheduled, 0), 1) as acceptance_rate,
CASE
WHEN times_confirmed::numeric / NULLIF(times_scheduled, 0) >= 0.9 THEN 'Preferred'
WHEN times_declined::numeric / NULLIF(times_scheduled, 0) >= 0.5 THEN 'Not Preferred'
ELSE 'Neutral'
END as time_preference
FROM scheduling_patterns
WHERE times_scheduled >= 3 -- Minimum data for pattern
ORDER BY full_name, acceptance_rate DESC;
Song & Worship Analytics
Song Rotation Optimization
Copy
-- Analyze song usage patterns and suggest rotation
WITH song_usage AS (
SELECT
s.song_id,
s.title,
s.author,
COUNT(DISTINCT i.plan_id) as total_uses,
COUNT(DISTINCT DATE_TRUNC('month', pl.sort_date)) as months_used,
MIN(pl.sort_date) as first_used,
MAX(pl.sort_date) as last_used,
AVG(pl.sort_date - LAG(pl.sort_date) OVER (PARTITION BY s.song_id ORDER BY pl.sort_date)) as avg_days_between
FROM planning_center.services_songs s
JOIN planning_center.services_items i ON s.song_id = i.song_id
JOIN planning_center.services_plans pl ON i.plan_id = pl.plan_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 year'
AND i.item_type = 'song'
GROUP BY s.song_id, s.title, s.author
),
song_categories AS (
SELECT
song_id,
title,
author,
total_uses,
months_used,
last_used,
CURRENT_DATE - last_used as days_since_last,
EXTRACT(DAYS FROM avg_days_between) as typical_gap_days,
CASE
WHEN total_uses >= 20 AND CURRENT_DATE - last_used > 60 THEN 'Overdue - High Rotation'
WHEN total_uses >= 10 AND CURRENT_DATE - last_used > 90 THEN 'Overdue - Medium Rotation'
WHEN total_uses >= 5 AND CURRENT_DATE - last_used > 120 THEN 'Overdue - Low Rotation'
WHEN CURRENT_DATE - last_used < 14 THEN 'Recently Used'
WHEN total_uses < 3 THEN 'New/Rarely Used'
ELSE 'Normal Rotation'
END as rotation_status
FROM song_usage
)
SELECT
title,
author,
total_uses,
days_since_last as days_since_last_use,
typical_gap_days as typical_days_between,
rotation_status,
CASE
WHEN rotation_status LIKE 'Overdue%' THEN 'Consider scheduling soon'
WHEN rotation_status = 'Recently Used' THEN 'Wait ' || GREATEST(0, typical_gap_days - days_since_last) || ' more days'
ELSE 'Normal scheduling'
END as recommendation
FROM song_categories
ORDER BY
CASE
WHEN rotation_status LIKE 'Overdue%' THEN 1
WHEN rotation_status = 'New/Rarely Used' THEN 2
ELSE 3
END,
total_uses DESC;
Key Progression Analysis
Copy
-- Analyze key changes within services for smooth transitions
WITH service_keys AS (
SELECT
pl.plan_id,
pl.title as plan_title,
pl.sort_date,
i.sequence,
i.title as item_title,
s.title as song_title,
a.chord_chart_key as song_key,
LAG(a.chord_chart_key) OVER (PARTITION BY pl.plan_id ORDER BY i.sequence) as previous_key,
LEAD(a.chord_chart_key) OVER (PARTITION BY pl.plan_id ORDER BY i.sequence) as next_key
FROM planning_center.services_items i
JOIN planning_center.services_plans pl ON i.plan_id = pl.plan_id
JOIN planning_center.services_songs s ON i.song_id = s.song_id
JOIN planning_center.services_arrangements a ON i.arrangement_id = a.arrangement_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
AND i.item_type = 'song'
AND a.chord_chart_key IS NOT NULL
)
SELECT
plan_title,
sort_date,
STRING_AGG(
CASE
WHEN song_key != previous_key AND previous_key IS NOT NULL
THEN previous_key || '→' || song_key
ELSE song_key
END,
' | ' ORDER BY sequence
) as key_progression,
COUNT(CASE WHEN song_key != previous_key AND previous_key IS NOT NULL THEN 1 END) as key_changes,
COUNT(DISTINCT song_key) as unique_keys
FROM service_keys
GROUP BY plan_id, plan_title, sort_date
ORDER BY sort_date DESC
LIMIT 20;
Song Theme Correlation
Copy
-- Analyze which themes are used together
WITH song_themes AS (
SELECT
pl.plan_id,
pl.title as plan_title,
s.song_id,
s.title as song_title,
UNNEST(STRING_TO_ARRAY(LOWER(s.themes), ',')) as theme
FROM planning_center.services_items i
JOIN planning_center.services_plans pl ON i.plan_id = pl.plan_id
JOIN planning_center.services_songs s ON i.song_id = s.song_id
WHERE s.themes IS NOT NULL AND s.themes != ''
AND pl.sort_date >= CURRENT_DATE - INTERVAL '6 months'
AND i.item_type = 'song'
),
theme_pairs AS (
SELECT
t1.theme as theme1,
t2.theme as theme2,
COUNT(DISTINCT t1.plan_id) as plans_together
FROM song_themes t1
JOIN song_themes t2 ON t1.plan_id = t2.plan_id
AND t1.song_id < t2.song_id
AND t1.theme < t2.theme
GROUP BY t1.theme, t2.theme
)
SELECT
TRIM(theme1) as theme_1,
TRIM(theme2) as theme_2,
plans_together as services_paired,
ROUND(plans_together::numeric * 100 / (
SELECT COUNT(DISTINCT plan_id)
FROM song_themes
), 1) as percentage_of_services
FROM theme_pairs
WHERE plans_together >= 3
ORDER BY plans_together DESC
LIMIT 25;
Service Planning Intelligence
Optimal Service Length Analysis
Copy
-- Analyze service length patterns and attendance correlation
WITH service_lengths AS (
SELECT
st.name as service_type,
pt.name as time_name,
pl.plan_id,
pl.sort_date,
pl.total_length / 60 as length_minutes,
pl.plan_people_count as volunteers,
EXTRACT(DOW FROM pl.sort_date) as day_of_week,
EXTRACT(MONTH FROM pl.sort_date) as month
FROM planning_center.services_plans pl
JOIN planning_center.services_service_types st ON pl.service_type_id = st.service_type_id
LEFT JOIN planning_center.services_plan_times pt ON pl.plan_id = pt.plan_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 year'
AND pl.total_length > 0
AND pt.time_type = 'service'
),
length_stats AS (
SELECT
service_type,
time_name,
AVG(length_minutes) as avg_length,
STDDEV(length_minutes) as stddev_length,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY length_minutes) as q1_length,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY length_minutes) as median_length,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY length_minutes) as q3_length,
MIN(length_minutes) as min_length,
MAX(length_minutes) as max_length,
COUNT(*) as service_count
FROM service_lengths
GROUP BY service_type, time_name
)
SELECT
service_type,
time_name,
service_count,
ROUND(avg_length, 1) as avg_minutes,
ROUND(median_length, 1) as median_minutes,
ROUND(stddev_length, 1) as variation,
ROUND(min_length, 0) || '-' || ROUND(max_length, 0) as range_minutes,
ROUND(q1_length, 0) || '-' || ROUND(q3_length, 0) as typical_range,
CASE
WHEN stddev_length / NULLIF(avg_length, 0) > 0.2 THEN 'High Variation'
WHEN stddev_length / NULLIF(avg_length, 0) > 0.1 THEN 'Moderate Variation'
ELSE 'Consistent'
END as consistency
FROM length_stats
WHERE service_count >= 5
ORDER BY service_type, time_name;
Item Type Distribution
Copy
-- Analyze the composition of services
WITH item_analysis AS (
SELECT
st.name as service_type,
pl.plan_id,
pl.title,
COUNT(*) as total_items,
COUNT(CASE WHEN i.item_type = 'song' THEN 1 END) as songs,
COUNT(CASE WHEN i.item_type = 'header' THEN 1 END) as headers,
COUNT(CASE WHEN i.item_type = 'media' THEN 1 END) as media,
COUNT(CASE WHEN i.item_type = 'item' THEN 1 END) as other_items,
SUM(i.length) / 60 as total_minutes,
SUM(CASE WHEN i.item_type = 'song' THEN i.length ELSE 0 END) / 60 as song_minutes
FROM planning_center.services_items i
JOIN planning_center.services_plans pl ON i.plan_id = pl.plan_id
JOIN planning_center.services_service_types st ON pl.service_type_id = st.service_type_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY st.name, pl.plan_id, pl.title
)
SELECT
service_type,
AVG(total_items) as avg_items,
AVG(songs) as avg_songs,
AVG(headers) as avg_headers,
AVG(media) as avg_media,
AVG(other_items) as avg_other,
ROUND(AVG(song_minutes), 1) as avg_music_minutes,
ROUND(AVG(song_minutes) * 100 / NULLIF(AVG(total_minutes), 0), 1) as music_percentage
FROM item_analysis
GROUP BY service_type
ORDER BY service_type;
Team Scheduling Optimization
Find Best Team Combinations
Copy
-- Identify teams that work well together
WITH team_combinations AS (
SELECT
pp1.plan_id,
t1.name as team1,
t2.name as team2,
COUNT(*) OVER (PARTITION BY t1.team_id, t2.team_id) as times_together,
AVG(CASE WHEN pp1.status = 'C' AND pp2.status = 'C' THEN 1.0 ELSE 0 END)
OVER (PARTITION BY t1.team_id, t2.team_id) as both_confirm_rate
FROM planning_center.services_plan_people pp1
JOIN planning_center.services_plan_people pp2 ON pp1.plan_id = pp2.plan_id
AND pp1.team_id < pp2.team_id
JOIN planning_center.services_teams t1 ON pp1.team_id = t1.team_id
JOIN planning_center.services_teams t2 ON pp2.team_id = t2.team_id
JOIN planning_center.services_plans pl ON pp1.plan_id = pl.plan_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '6 months'
)
SELECT DISTINCT
team1,
team2,
times_together,
ROUND(both_confirm_rate * 100, 1) as both_confirm_percentage,
CASE
WHEN both_confirm_rate >= 0.9 AND times_together >= 10 THEN 'Excellent Pairing'
WHEN both_confirm_rate >= 0.8 AND times_together >= 5 THEN 'Good Pairing'
WHEN both_confirm_rate < 0.6 THEN 'Consider Separating'
ELSE 'Neutral'
END as recommendation
FROM team_combinations
WHERE times_together >= 5
ORDER BY both_confirm_rate DESC, times_together DESC;
Volunteer Availability Forecast
Copy
-- Predict volunteer availability based on historical patterns
WITH volunteer_history AS (
SELECT
p.person_id,
p.full_name,
DATE_TRUNC('month', pl.sort_date) as month,
COUNT(*) as times_scheduled,
COUNT(CASE WHEN pp.status = 'C' THEN 1 END) as times_available,
COUNT(CASE WHEN pp.status = 'D' THEN 1 END) as times_unavailable
FROM planning_center.services_people p
JOIN planning_center.services_plan_people pp ON p.person_id = pp.person_id
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '12 months'
AND pl.sort_date < CURRENT_DATE
GROUP BY p.person_id, p.full_name, DATE_TRUNC('month', pl.sort_date)
),
availability_trends AS (
SELECT
person_id,
full_name,
AVG(times_available::numeric / NULLIF(times_scheduled, 0)) as avg_availability,
STDDEV(times_available::numeric / NULLIF(times_scheduled, 0)) as availability_variance,
COUNT(DISTINCT month) as months_active
FROM volunteer_history
GROUP BY person_id, full_name
HAVING COUNT(DISTINCT month) >= 3 -- Minimum history for prediction
)
SELECT
full_name,
ROUND(avg_availability * 100, 1) as historical_availability_pct,
ROUND(availability_variance * 100, 1) as variance_pct,
months_active,
CASE
WHEN avg_availability >= 0.9 AND availability_variance < 0.1 THEN 'Very Reliable'
WHEN avg_availability >= 0.8 AND availability_variance < 0.2 THEN 'Reliable'
WHEN avg_availability >= 0.7 THEN 'Moderately Reliable'
WHEN avg_availability >= 0.5 THEN 'Variable Availability'
ELSE 'Limited Availability'
END as reliability_rating,
ROUND(avg_availability * 4, 0) as predicted_available_per_month
FROM availability_trends
ORDER BY avg_availability DESC, availability_variance;
Performance Monitoring
Service Preparation Timeline
Copy
-- Track how far in advance teams confirm
WITH confirmation_timeline AS (
SELECT
t.name as team,
pp.status,
pl.sort_date as service_date,
pp.status_updated_at as confirmation_date,
pl.sort_date - pp.status_updated_at as days_before_service
FROM planning_center.services_plan_people pp
JOIN planning_center.services_teams t ON pp.team_id = t.team_id
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
WHERE pp.status = 'C'
AND pp.status_updated_at IS NOT NULL
AND pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
)
SELECT
team,
COUNT(*) as total_confirmations,
ROUND(AVG(EXTRACT(DAYS FROM days_before_service)), 1) as avg_days_advance,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(DAYS FROM days_before_service)) as median_days_advance,
COUNT(CASE WHEN days_before_service >= INTERVAL '7 days' THEN 1 END) as confirmed_week_plus,
COUNT(CASE WHEN days_before_service < INTERVAL '2 days' THEN 1 END) as last_minute,
ROUND(
COUNT(CASE WHEN days_before_service >= INTERVAL '7 days' THEN 1 END)::numeric * 100 /
NULLIF(COUNT(*), 0), 1
) as pct_early_confirmation
FROM confirmation_timeline
GROUP BY team
ORDER BY avg_days_advance DESC;
Tips for Advanced Queries
CTEs (WITH clauses): Break complex logic into readable steps. Each CTE builds on the previous one.
Window Functions: Use OVER() clauses for running totals, rankings, and comparisons within groups.
Performance Considerations: These queries process significant data. Consider adding indexes on frequently filtered columns like sort_date and person_id.
Next Steps
Ready to build comprehensive reports? Check out our Services Reporting Examples for:- Complete volunteer dashboards
- Worship planning analytics
- Team health scorecards
- Service effectiveness metrics
- Multi-campus coordination reports