# Accessing Your Data
Source: https://docs.getparable.io/access-data
Query your synced church data from the SQL editor, a database client, or a BI tool
Parable gives you two ways to query your synced Planning Center data directly:
the built-in SQL editor, and a read-only PostgreSQL connection you can point any
tool at.
## Option 1: The Built-In SQL Editor
The fastest path — nothing to install or configure.
Open **SQL Editor** from the sidebar. It includes:
* Schema browsing and autocomplete across every synced table
* Query history and saved queries
* CSV export, including background export for large result sets
* Query cancellation for long-running work
Start here. Use an external client only when you need a tool Parable doesn't
provide — scheduled extracts, a BI semantic layer, or a notebook workflow.
## Option 2: Your Own Database Client or BI Tool
### Getting Your Connection String
Select **Create Connection** and give it a name you'll recognize
(for example, "Power BI" or "Michael's laptop").
Parable generates and displays the username, password, host, database name,
and a complete connection string.
The password is stored encrypted, and you can reopen it later — press
**Connect** on the connection under **Settings → Database** to see the
password and full connection string again. You can hold several
connections at once and revoke them individually.
Your connection string looks like this:
```text theme={null}
postgres://username:password@host:5432/database
```
You don't supply a database — Parable hosts the warehouse and provisions a
dedicated role scoped to your organization.
### What the Connection Can Do
The role Parable creates is deliberately narrow:
| | |
| ------------- | -------------------------------------------------------------------------------------- |
| **Access** | `SELECT` only |
| **Schema** | `planning_center`, plus the `giving_custom_units` tables used by custom giving reports |
| **Isolation** | Row-level security restricts every query to your organization |
| **Filtering** | Only `active` records are visible by default |
You cannot create tables, views, materialized views, or indexes through this
connection, and you cannot write to any table. `INSERT`, `UPDATE`, `DELETE`, and
DDL will all be rejected. This is by design — your Planning Center data stays
authoritative and unmodified.
A "permission denied" error on a write or `CREATE` statement is not a
misconfiguration. It's the guardrail working.
### Connecting a Database Client
1. Click **+** to create a new connection and choose **PostgreSQL**
2. Fill in **Host**, **Port** (5432), **User**, **Password**, and **Database**
from your Parable credentials
3. Set SSL mode to **Require**
4. **Test**, then **Connect**
1. Right-click **Servers** → **Register** → **Server**
2. On **General**, name the connection
3. On **Connection**, enter host, port 5432, database, username, and password
4. On **Parameters**, set **SSL mode** to `require`
5. **Save**
1. Click **+** in the Database tool window → **Data Source** → **PostgreSQL**
2. Enter host, port 5432, database, user, and password
3. On the **SSH/SSL** tab, enable SSL and set mode to `require`
4. **Test Connection**, then **OK**
```bash theme={null}
psql "postgres://username:password@host:5432/database?sslmode=require"
```
### Connecting a BI Tool
**Get Data → PostgreSQL database**. Enter the host and database name, choose
**DirectQuery** for live data or **Import** for a cached extract, then supply
the username and password when prompted.
Import mode is usually the better choice — it keeps report interactions fast
and reduces load on the warehouse.
**Connect → PostgreSQL**. Enter server, port 5432, database, username, and
password, and require SSL. Then either drag tables onto the canvas or use
**Custom SQL** with one of the queries from these docs.
**Admin → Databases → Add database → PostgreSQL**. Enter host, port,
database, username, and password, and enable **Use a secure connection
(SSL)**. Metabase will scan the `planning_center` schema automatically.
## Understanding the Schema
All synced Planning Center data lives in the `planning_center` schema, named
`{app}_{entity}`:
```sql theme={null}
-- List everything available to you
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'planning_center'
ORDER BY table_name;
```
Common starting points:
| Table | Contains |
| --------------------------------------- | ------------------------ |
| `planning_center.people_people` | Every person record |
| `planning_center.people_households` | Household groupings |
| `planning_center.giving_donations` | Individual donations |
| `planning_center.giving_funds` | Fund definitions |
| `planning_center.checkins_check_ins` | Attendance check-ins |
| `planning_center.groups_groups` | Small groups and classes |
| `planning_center.groups_memberships` | Group membership records |
| `planning_center.services_plans` | Worship service plans |
| `planning_center.calendar_events` | Church calendar events |
| `planning_center.registrations_signups` | Event signups |
### Relationships Live in Their Own Tables
This is the single most important thing to know before writing queries. Entity
tables carry **no foreign-key columns**. Every link between records lives in a
matching `*_relationships` table:
```sql theme={null}
-- A donation's donor is NOT giving_donations.person_id.
-- It's a row in giving_donations_relationships.
SELECT
p.first_name,
p.last_name,
d.amount_cents / 100.0 as amount,
d.received_at
FROM planning_center.giving_donations d
JOIN planning_center.giving_donations_relationships dr
ON dr.donation_id = d.donation_id
AND dr.relationship_type = 'Person'
JOIN planning_center.people_people p
ON p.person_id = dr.relationship_id
WHERE d.payment_status = 'succeeded'
ORDER BY d.received_at DESC
LIMIT 100;
```
Each relationship table has three meaningful columns: the parent entity's ID
(named after the parent table, for example `donation_id`), `relationship_type`
(what kind of record it points at), and `relationship_id` (the ID of that
record).
See [Planning Center Overview](/planning-center/index) for the full model, and
each module's data-model page for its relationship types.
## First Queries
```sql theme={null}
-- How many active people do we have?
SELECT COUNT(*) as active_people
FROM planning_center.people_people
WHERE status = 'active';
```
```sql theme={null}
-- Giving by month over the last year
SELECT
DATE_TRUNC('month', received_at) as month,
COUNT(*) as gifts,
SUM(amount_cents) / 100.0 as total_dollars
FROM planning_center.giving_donations
WHERE payment_status = 'succeeded'
AND received_at >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', received_at)
ORDER BY month DESC;
```
```sql theme={null}
-- Group participation
SELECT
g.name as group_name,
COUNT(DISTINCT mr_person.relationship_id) as members
FROM planning_center.groups_groups g
JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.relationship_id = g.group_id
AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.membership_id = mr_group.membership_id
AND mr_person.relationship_type = 'Person'
WHERE g.archived_at IS NULL
GROUP BY g.name
ORDER BY members DESC;
```
## Best Practices
### Let Row-Level Security Do Its Job
Every query is automatically scoped to your organization and to `active`
records. Adding these filters yourself is redundant and can slow queries down:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
### Understand the Two Kinds of Timestamp
* `created_at` / `updated_at` — when the record changed **in Planning Center**.
Use these for ministry metrics.
* `system_created_at` / `system_updated_at` — when Parable synced the record.
Use these to debug sync behavior, not to measure ministry.
### Query Performance
* Filter by date range before joining relationship tables
* Add `LIMIT` while exploring
* Select the columns you need rather than `SELECT *` on large tables
* Cache heavy summaries in your BI tool — you can't create materialized views
through this connection
### Security
* Treat the connection string like a password; never commit it or share it publicly
* Create a separate connection per tool or person so you can revoke one without
disrupting the others
* Delete connections you no longer use from **Settings → Database**
## Troubleshooting
**"Could not connect to server"**
Confirm the host and port (5432) are exactly as shown in Settings → Database,
and that SSL is enabled in your client.
**"Authentication failed"**
Passwords can't be recovered after creation. If you don't have it saved, delete
the connection and create a new one.
**"Permission denied" on a write or `CREATE`**
Expected. The connection is read-only by design.
**A table looks empty**
Check that the corresponding Planning Center app is connected and has finished
its first sync under **Settings → Sync**.
## Getting Help
* Ask in the [community Slack](https://join.slack.com/t/theparablecommunity/shared_invite/zt-3btkowr37-1kNbjthQJ6EVVty2tm2KNQ)
* Email [michael@getparable.io](mailto:michael@getparable.io)
* Browse module-specific query examples under [Planning Center](/planning-center/index)
# Introduction
Source: https://docs.getparable.io/api-reference/introduction
The Parable REST API
## Welcome
The Parable API is the REST API that powers the Parable web application. It
covers organizations and members, Planning Center integrations and sync,
dashboards, reports, widgets, metrics, engagement scoring, the SQL editor, and
chat.
The API is currently used by the Parable app itself and is not yet a supported
public integration surface — endpoints and payloads can change without notice.
If you'd like to build against it, email
[michael@getparable.io](mailto:michael@getparable.io) and we'll work with you
directly.
## Base URLs
| Environment | URL |
| ----------- | -------------------------------- |
| Production | `https://api.getparable.com` |
| Development | `https://api-dev.getparable.com` |
## Authentication
Requests are authenticated with a bearer token issued by
[WorkOS AuthKit](https://workos.com/docs/authkit), the identity provider behind
Parable sign-in:
```text theme={null}
Authorization: Bearer
```
Most endpoints are scoped to an organization and take the organization ID in the
path, for example `/api/v1/org/{orgId}/reports`. Access is enforced by
role-based permissions — a token only reaches the organizations and actions its
user is entitled to.
## Conventions
* All request and response bodies are JSON.
* Paths are versioned under `/api/v1`.
* List endpoints are paginated; check each endpoint for its pagination parameters.
* Monetary values are integers in cents, matching Planning Center.
* Timestamps are RFC 3339 in UTC.
## Looking for Data Access Instead?
If your goal is to query your church's data rather than drive the application,
a direct SQL connection is usually the better path — see
[Accessing Your Data](/access-data).
## Questions
Reach out at [michael@getparable.io](mailto:michael@getparable.io) with feedback
on what you'd like to see documented here.
# Changelog
Source: https://docs.getparable.io/changelog
Product updates and announcements
## Latest Updates
Stay informed about the latest improvements to Parable.
Giving reporting picked up custom units, Connections gained a daily care escalation digest, and Settings moved to a grouped side rail.
### New Features
* **Custom Giving Units**: Group funds into named reporting units so giving reports can roll up the way your finance team actually talks about them
* **Care Escalation Digest**: A daily Connections digest that surfaces overdue touchpoints and people who have gone quiet
* **Grouped Settings Navigation**: Settings is now organized into a sectioned side rail instead of one long list
* **Enterprise Subscriptions**: Support for attaching custom enterprise billing arrangements
### Improvements
* **Groups Sync**: Memberships now sync batched by parent group, cutting the number of Planning Center round trips
* **Services Sync**: Retired the per-person Emails fan-out and bounded staged-email sync-state work, substantially reducing Services sync time
### Bug Fixes
* **Engagement Distribution**: Corrected warning-light totals so Lapsed and Dormant people are no longer missing from the top-level distribution
* **Engagement Statuses**: Added complete Inactive tier support and aligned every warning-light label and color with the API contract
* **Services Rotation**: Reset the rotation cursor on a completed traversal so later parents are no longer starved
* **Chat Retention**: Added a row-level metadata retention backstop for chat history
Connections gained tags, shared assignments, and Planning Center care context, and dashboards got a global campus filter.
### New Features
* **Person Tags**: Tag people in Connections and filter care lists by tag
* **Shared Care Assignments**: More than one leader can be responsible for the same person
* **Person-User Linking**: Link a Parable user to their Planning Center person record so "my people" resolves correctly
* **Leader Group Scope**: Scope a leader's Connections access to the groups they actually lead
* **Planning Center Care Context**: Surface existing Planning Center notes and context alongside Parable touchpoints
* **AI Care Profiles**: Chat can now summarize a person's care history and suggest next steps
* **Global Campus Filter**: Filter an entire dashboard by campus, with check-in location filters for attendance metrics
* **Services Team Hierarchy**: Team parent/child relationships are now synced and queryable
### Improvements
* **Chat Model Routing**: Free-text chat now routes to Sonnet 5 for faster, stronger answers
* **AI Observability**: Added generation-level analytics so we can track and improve answer quality
### Bug Fixes
* **Connections**: A round of fixes across assignment, tagging, and needs-attention behavior
* **Metrics & People**: Corrected campus attribution and person list edge cases
The biggest release of the summer: a dedicated care workspace, a full person profile, and dashboards you can share by link.
### New Features
* **Connections Workspace**: A focused surface for pastoral follow-up — a needs-attention list, a leaders accountability board, assignment flows, and touchpoint logging. Available to selected organizations while we refine it
* **Touchpoint Write-Back**: Touchpoints logged in Parable can be written back to Planning Center as notes
* **Full Person Profile**: Person records now open a complete profile page with a redesigned header, overview, icon-based activity timeline, and engagement tab
* **Public Dashboard Sharing**: Publish a dashboard by link, with its own date picker and preset ranges
* **Report Spec Builder**: A new report-building loop with configurable visualization options and value pickers
* **Queued CSV Exports**: Large SQL editor exports now run in the background instead of timing out
### Improvements
* **Stale Data Indicators**: Widgets and reports now show when their Planning Center data is behind, instead of silently displaying old numbers
* **Design System**: Expanded the Geist color system across the app, including a consistent destructive palette
### Bug Fixes
* **Broad Polish**: Roughly a hundred fixes across Connections, reports, dashboards, onboarding, chat, widgets, and Planning Center sync
Reports moved onto a compiled spec with a real semantic layer, and AI-generated giving SQL got substantially safer.
### New Features
* **Report Spec Compiler**: Reports now compile from a structured spec, with an expanded semantic layer, time-series transforms, and grouped calculations
* **Preview Inspector**: Inspect exactly what a report preview is computing while you build it
* **Benchmark Gauges**: Target gauges backed by the benchmarks you configure in Settings
* **Getting Started Checklist**: A guided activation checklist for new organizations
* **Dashboard Collaborators**: A collaborator sharing model for dashboards
* **Per-Integration Sync Status**: See sync state broken out by integration rather than in aggregate
### Improvements
* **Engagement Retention**: Daily engagement snapshots now age out on a defined retention schedule, keeping history useful without unbounded growth
### Bug Fixes
* **Giving Accuracy Guardrails**: An extensive hardening pass on fund- and designation-scoped giving SQL, closing dozens of ways a generated query could double-count donations across funds
* **Bulk PDF Export**: Made bulk exports recoverable, correctly scoped to their creator, and honest about timeouts and progress
* **Sync Reliability**: Fixed empty-batch finalization, service selector continuations, and Planning Center list sync recovery visibility
Report editing consolidated into one place, and giving reports learned to allocate by designation.
### New Features
* **Canonical Giving Specs**: Built-in query specs for the giving questions churches ask most
* **CSV Export Scope Options**: Choose what a report export includes before you download it
* **New Drill-In Canvases**: Overserving alerts and pending service responses now open detailed views
* **Group Widget Scoping**: Restrict group widgets to specific group types
* **Reconnect Alerts**: A broken Planning Center connection now surfaces in the global navigation
### Improvements
* **One Editing Surface**: The report builder is now the single place reports are edited, with clearer refinement actions
* **Smarter AI Reporting**: Chat reuses cohorts for derived metrics and follows improved SQL relationship guidance
* **SQL Guardrails**: Moved query validation onto a real Postgres parser rather than pattern matching
### Bug Fixes
* **Fund-Specific Giving**: Fund reports now allocate through designations instead of whole donations, correcting totals for split gifts
* **Long-Running Syncs**: Workflow card notes now continue across workflow boundaries instead of starving on an 8-hour timeout
* **Deleted Records**: People and messages deleted in Planning Center are now pruned via a version-gated reconcile
Engagement gained two long-horizon statuses, and more dashboard widgets became explorable.
### New Features
* **Lapsed and Dormant Lights**: New warning-light states for people inactive 90+ and 180+ days, separating long-term absence from a recent dip
* **More Drill-In Canvases**: Birthdays, expiring background checks, upcoming events, and the weekly giving snapshot now open detailed views
* **Engagement Growing Window**: Customize the trailing window used to identify people whose engagement is improving
* **Billing-Aware Sync**: Disabling an integration pauses its schedules, and billing state can suspend and resume syncing
### Improvements
* **Diagnostics**: Stranded integrations are now detected and reported, and the daily email leads with actionable incidents rather than cascade-inflated counts
### Bug Fixes
* **Chat Accuracy**: Hardened the analytical chat harness for complex asks and corrected recurring giving date ranges
* **Reports**: Fixed Giving Overview SQL and guarded invalid builder saves
* **Data Correctness**: Scoped birthday contact joins by integration and preserved midnight timestamps
Chat became far more capable, dashboard widgets became configurable, and account and data controls landed.
### New Features
* **Chat Canvases and Tools**: Chat can now navigate the app, drive builder actions, stream its work log as it goes, and open dedicated canvases such as group sign-ups
* **Widget Settings**: A settings sheet for dashboard widgets, with customizable thresholds for attendance dropoff, overserving alerts, expiring checks, lapsed giving, and birthday/event windows
* **Report Builder**: Added table visualizations, metric scorecards, and a mobile preview canvas
* **Account Deletion**: Users can delete their own account, backed by a deletion audit log
* **Terms Re-Acceptance**: A blocking gate when terms of service change
### Improvements
* **Planning Center Custom Fields**: Engagement and giving write-back now use two separate tabs — **Parable** for engagement tier and warning light, and **Parable: Giving** for giving pattern, trend, and payment alerts. Existing fields are migrated automatically
* **Unified Settings**: Metric placement and flows settings were consolidated into single surfaces
* **Sync Performance**: Adaptive Planning Center rate pacing from quota headers, differential commits for people field data, and bounded parallelism in the master sync
### Bug Fixes
* **Sync Correctness**: A large pass across check-ins, registrations, services, and people covering pagination cursors, drift repair, and phantom sync states
* **Security**: Scoped SQL editor and chat routes by organization and required org access for billing creation
Dashboard widgets became configurable, and engagement weighting opened up.
### New Features
* **Widget Settings**: Storage and endpoints for per-widget configuration
* **Core Ratios**: A church health metrics view built on core ministry ratios
* **Editable Category Weights**: Engagement category weight percentages can now be edited directly
### Improvements
* **People Page Performance**: Optimized the count queries behind people list views
### Bug Fixes
* **Chat**: Scoped chat history to the selected organization and fixed engagement distribution drill-in
* **Dashboards**: Fixed attendance dashboard metric configuration, Group Roles labels, and the metrics date menu layering
* **Giving**: Pledge relationships now persist and pledge campaign totals reconcile correctly
* **Sync**: Deduplicated Planning Center transfer rows and corrected nested API totals across Services, Check-Ins, and Calendar
A dedicated diagnostics system landed so sync problems surface before they reach your reports.
### New Features
* **Hourly Diagnostics**: Automated detection of stalled, silently stopped, and never-finished syncs, with classified error causes
* **Tenant Scorecard**: An admin view of sync health per organization, with rolling health windows
* **Insufficient Scope Detection**: Planning Center permission gaps are now identified explicitly rather than surfacing as generic failures
### Improvements
* **Field Data Performance**: Batched the people field-data date refresh
* **Sync Bookkeeping**: Added last-seen sync IDs and tenant sync rollups so drift is measurable
### Bug Fixes
* **Sync Reliability**: A broad repair pass covering drift-cleared high-water marks, bounded repair routes, rotated master steps, serialized field-data writes, and child sync-state finalization
* **Cursor Handling**: Preserved Planning Center cursor page order and reconciled missing sync states
Planning Center sync coverage expanded, metrics flows became more flexible, and sync operations got another round of reliability improvements.
### New Features
* **Metrics Flows**: Added multi-condition flow steps so completion rules can combine multiple sources and match strategies
* **Planning Center Coverage**: Added incremental sync support across more People, Giving, Services, and Check-Ins data
### Improvements
* **Sync Operations**: Added orphaned schedule cleanup, stronger field-data pagination, and safer handling for empty full syncs
* **Diagnostics & Notifications**: Made timeout causes easier to diagnose and labeled staging admin emails more clearly
### Bug Fixes
* **Temporal Reliability**: Fixed worker startup, shutdown draining, heavy people queue handling, and field-data write throttling
* **Data & UI Accuracy**: Fixed weekly digest section bounds, donation-by-fund legend layout, dark-mode notification highlights, and orphaned event counting
Billing and email delivery got safer, engagement data became more accurate, and sync workflows picked up another round of reliability fixes.
### New Features
* **Roadmap Visibility**: Added a roadmap page so product direction is easier to track
### Improvements
* **Email Delivery Routing**: Routed Postmark sends by message stream for cleaner delivery handling across notification types
* **Engagement Accuracy**: Tightened engagement scoring inputs by using donation relationships and filtering inactive donations
### Bug Fixes
* **Billing Reliability**: Prevented invoice-page 503s for organizations without Stripe customers
* **Digest & Reporting UX**: Fixed weekly digest timeout behavior and preserved preview order when overview cards are reordered
* **Sync Stability**: Improved post-sync accounting, detached child workflows more safely, retried rate-limited Planning Center pages, and throttled group event note writes
Weekly digests launched, billing got clearer guardrails and receipt access, and sync and reporting workflows became more reliable.
### New Features
* **Weekly Digest Email**: Added a Monday post-sync weekly digest email with pastoral intelligence highlights
* **Billing Guidance**: Added soft-cap notifications, recommended plan tier guidance, and receipt access improvements
* **Data Coverage**: Expanded recurring donation sync coverage and improved People sidebar search and sort
### Improvements
* **Reports & Mobile UX**: Improved template autosave and campus data alignment in reports, plus better mobile layouts, drag interactions, and chat usability
* **Engagement Signals**: Tuned warning-light behavior with a configurable baseline and admin rebuild support
* **Planning Center Data Model**: Normalized relationship roles and renamed relationship tables and columns for cleaner synced data handling
### Bug Fixes
* **Temporal Reliability**: Added partial stage progress commits, increased post-sync reserve time, and ran migrations for the Temporal worker database
* **Frontend Polish**: Fixed drag preview synchronization issues across the updated interaction model
* **Data Integrity**: Repaired a stale giving donor constraint that could block engagement-related updates
Service attendance reporting expanded, AI chat got better at asking for missing context, and report and sync workflows became more reliable.
### New Features
* **Service Attendance Reporting**: Added service attendance widgets, canvases, and chat analysis with accountability tracking for serving teams
* **Chat Clarifications**: AI chat can now ask for one missing detail before continuing, so report and analysis requests stay on track with less guesswork
### Improvements
* **Report Builder**: Improved report-builder chat session handling and kept AI-generated titles and descriptions in sync more reliably after restores
* **Chat & Reporting**: Improved reporting validation, AI chat guardrails, and retry behavior for a steadier reporting experience
* **Sync Runtime Control**: Added better time-budget handling so long-running syncs can exit more gracefully after saving partial progress
### Bug Fixes
* **Sync Reliability**: Improved engagement scoring heartbeats, stopped report poller batch replays, and rate-limited groups RSVP syncs
* **Data & Quality Checks**: Enforced metrics group-type validation on updates, fixed people sync auto-tuning persistence, and resolved repo-wide golangci-lint failures
Engagement automation expanded, admins gained tighter sync controls, and recent sync and billing workflows became more reliable.
### New Features
* **Engagement Scoring**: Expanded giving-based engagement classification, Planning Center write-back recipes, and refined score inputs for group and attendance analysis
* **Admin Sync Controls**: Added enable or disable controls for integrations plus cancel-sync actions for individual scopes or entire runs
### Improvements
* **Sync Throughput**: Added incremental sync support for Services items and improved commit performance with batched operations
* **Workflow Operations**: Improved Temporal diagnostics, onboarding recovery, and self-tuning workflow parameter infrastructure
### Bug Fixes
* **Billing Checkout**: Enabled promotion codes more safely and fixed subscription checkout handling around Stripe payment setup
* **Access & Notifications**: Clarified giving-role permissions in admin dialogs and fixed notification email assets for better client compatibility
Scheduled reports are now automated end to end, with fresher list syncs and more reliable data across the board.
### New Features
* **Scheduled Reports**: Create recurring report schedules, choose recipients, pick your export format (PDF or CSV), and send reports on demand
* **List Freshness**: People lists now show when they are out of date and can be refreshed individually with one click
### Improvements
* **Report Styling**: Improved template rendering so report layouts display more accurately
* **Sync Coverage**: Re-enabled additional Planning Center sync workflows for more complete data
### Bug Fixes
* **Sync Reliability**: Resolved false stale-sync indicators on the integrations page after page refreshes
* **Date Accuracy**: Improved timezone handling and giving date filters for more accurate dashboards and reports
Billing is clearer, trials are easier to start, and engagement scoring now reflects recent activity more accurately.
### New Features
* **Free Trial**: Added a 30-day free trial with no credit card required
* **Billing Controls**: Added plan limits, gifted and admin overrides, Stripe billing, and AI credit tracking
* **Engagement Cadence**: Redesigned engagement scoring around monthly and weekly rhythms with EMA-based warning lights
### Improvements
* **Billing Experience**: Refreshed billing and usage screens so plan status and AI consumption are easier to understand
* **Organization Switching**: Improved org selection so users land in the organization they chose and keep chat context aligned
### Bug Fixes
* **Permission Failures**: Integrations now deactivate on real Planning Center permission failures without mislabeling onboarding states
* **Distribution Accuracy**: Fixed engagement distribution edge cases around disengaged and no-activity profiles
AI-powered analysis took a big step forward with better report generation, deeper giving insights, and faster chat responses.
### New Features
* **AI Report Builder**: Generate charts from chat, validate SQL, add goal and average lines, and save results to dashboards
* **Pledge Reporting**: Added pledge campaign tools, widgets, and canvases for fulfillment tracking
* **Giving Insights**: Added first-time giver reporting and payment channel support for more detailed giving analysis
### Improvements
* **Chat Performance**: Parallelized tool execution and expanded AI context loading across Services, Giving, Groups, and Calendar data
* **Web Search Mode**: Improved web-search chat sessions so sources are clearer and SQL context stays isolated
### Bug Fixes
* **SQL Canvas**: Reduced version bloat and preserved query context more reliably
* **Charts & Labels**: Improved comparison chart handling and ensured attendance charts show full X-axis labels
Report creation moved beyond SQL into full document workflows, while admins gained better tools to diagnose sync failures.
### New Features
* **Template Builder**: Create HTML and CSS report templates with AI chat, reference images, and safer previews
* **People Reports**: Added people pages, segment management, workflow filters, and bulk PDF export
* **Workflow Diagnostics**: Added an admin diagnostics dashboard with saved findings, detail views, copy tools, and AI-ready debug prompts
### Improvements
* **Reconnect Flow**: Added a guided Planning Center reconnect flow when credentials expire
* **Background Processing**: Moved bulk PDF export and message backfills into stronger Temporal workflows with better admin visibility
### Bug Fixes
* **AI SQL Quality**: Improved SQL validation, recovery, and schema enrichment for more reliable AI-generated queries
* **Planning Center Data Model**: Expanded synced fields and cleaned up relationship handling across People, Groups, Giving, Publishing, Calendar, and Check-Ins
Parable now does more of the operational work for you, from proactive email updates to self-serve billing controls.
### New Features
* **Email Notifications**: Added onboarding and sync notifications powered by Postmark
* **Billing**: Added Stripe subscription checkout, usage meters, AI credit purchases, gifted plans, and admin plan overrides
* **Giving Permissions**: Added stronger access controls for sensitive giving data
### Improvements
* **Onboarding Visibility**: Live onboarding sync scope labels make imports easier to follow
* **Engagement Control**: Syncing engagement scores back to Planning Center is now opt-in
### Bug Fixes
* **Notification Reliability**: Reduced duplicate sends and tightened recipient validation for email events
* **Preferences Safety**: Frontend preference loading now falls back cleanly if saved JSON is malformed
Dashboards and reports became much more expressive, especially for comparisons, metrics, and people-facing insights.
### New Features
* **Comparison Charts**: Added grouped-bar and multi-line period-over-period charts
* **Metrics UX**: Added goal lines, widget preferences, metric tooltips, and profile editing
* **New Data Sources**: Added workflow cards, registrations sources, and groups attendance reporting
### Improvements
* **Giving Insights**: Improved giving comparisons, formatting, and dashboard behavior for richer analysis
* **Data Discovery**: Added better pagination in data source pickers so larger churches can browse sources more easily
### Bug Fixes
* **Chart Accuracy**: Fixed wide-form comparison edge cases so saved reports and dashboard tiles render the right series
* **Frontend Stability**: Fixed report rendering regressions, invite creation failures, and a few organization-switching edge cases
Planning Center coverage expanded significantly, and the sync platform got a stronger operational foundation.
### New Features
* **Current App Support**: Added support for Planning Center Current data
* **Metrics Foundation**: Added backend configuration for custom metrics and flows
* **Sync Health**: Added health checks and pagination validation to catch sync issues earlier
### Improvements
* **Engagement Coverage**: Added an inactive tier for non-active Planning Center profiles
* **Operational Data**: Added groups attendance tools and better workflow card data for reporting
### Bug Fixes
* **Compatibility**: Improved Safari AI streaming, PgBouncer support, and Planning Center field sizing for more reliable production use
* **Data Safety**: Added text sanitization before Postgres writes and fixed synced relationship typing issues
Widget metrics and sync reliability improved while the engagement rollout was stabilized for existing organizations.
### New Features
* **Widget Metrics**: Added widget metrics, canvas validation, and stronger dashboard foundations
### Improvements
* **Sync Stability**: Eliminated recurring Planning Center 404 sync noise and hardened deployment behavior
* **Canvas Validation**: Added stronger safeguards so charts and saved canvases are more reliable
### Bug Fixes
* **Existing Organizations**: Seeded default engagement settings for older organizations
* **Planning Center Types**: Fixed field-type issues that could affect synced data quality
Parable introduced a more opinionated engagement layer to help churches spot trends faster after their data syncs.
### New Features
* **Engagement Scoring**: Added warning-light engagement scoring with configurable categories
* **Onboarding Flow**: Improved onboarding with a dedicated import step and clearer progress
### Improvements
* **Deployment Readiness**: Hardened Railway and Railpack configuration for steadier releases
### Bug Fixes
* **Rollout Safety**: Smoothed the engagement launch for existing organizations so default settings are available immediately
The first 2026 platform pass focused on making Parable easier to build, deploy, and improve quickly.
### Improvements
* **Platform Foundation**: Reorganized the product into a monorepo so the API, frontend, and docs can ship together more cleanly
* **Release Quality**: Added unified CI, Temporal dev targets, and SQL query validation to catch issues earlier
* **Testing**: Added ephemeral PostgreSQL test infrastructure for faster, more isolated backend testing
### Bug Fixes
* **Developer Workflow**: Cleaned up migration and local environment issues that could slow down delivery
### New Features
* **AI Chat**: AI chat to help you get started with Parable and Planning Center
* **Docs**: Data model visualizations and automated validation to keep guides accurate
### New Features
* **SQL Editor**: Full SQL Editor foundation
* **OAuth**: Backend-driven OAuth redirects with named routes for smoother auth flows
### New Features
* **Scoped Syncs**: Added scope filtering so integrations can run only the modules you choose
### Performance & Reliability
* **Temporal**: Improved naming consistency and removed hard timeouts for steadier workflow execution
### Bug Fixes
* **Sync Reliability**: Fixed issues where some configuration settings weren't being properly saved
* **OAuth Handling**: Improved authentication process to ensure seamless connection with Planning Center
### New Features
* **People Tabs**: Added support for syncing custom tabs from Planning Center People, giving you access to all your custom data fields
### Performance & Reliability
* **Data Storage**: Improved data organization and storage efficiency for faster queries and better performance
### Performance & Reliability
* **Enhanced Relationships**: Improved how we store and retrieve related data across Calendar and Groups for more accurate reporting
* **Documentation**: Added comprehensive guides for better system understanding
### New Features
* **Services Module**: Added full support for Planning Center Services app data synchronization, including plans, teams, and schedules
* **Archived Groups**: Now syncing archived groups for complete historical data and reporting
### Bug Fixes
* **Services Workflow**: Fixed timing issues that could affect Services data synchronization
### New Features
* **Integration Management**: Added ability to disconnect integrations when you need to remove access
### Performance & Reliability
* **Error Handling**: Better error messages when Planning Center modules aren't set up yet or access is denied
* **Workflow Stability**: Improved system reliability when multiple syncs happen simultaneously
### Performance & Reliability
* **Network Resilience**: Improved handling of large datasets and network issues with automatic retry logic
* **Large Datasets**: Enhanced timeout handling for churches with extensive registration data
### New Features
* **Publishing Module**: Added full support for Publishing app data synchronization, enabling churches to manage bulletins and content
* **Registrations Module**: Introduced comprehensive Registrations workflow for event and form management
* **Manual Sync**: Added ability to trigger data synchronization on-demand whenever you need fresh data
### Performance & Reliability
* **Faster Sync**: Significantly improved sync reliability for churches with large amounts of data
* **Better Stability**: Enhanced connection handling to ensure uninterrupted data synchronization
* **Improved Search**: Faster and more accurate filtering when working with your church data
### Bug Fixes
* **Check-ins Locations**: Resolved issue where some location information wasn't displaying properly
* **Name Fields**: Fixed an issue that could affect how names are stored and displayed
* **Sync Performance**: Improved data synchronization speed by streamlining background processes
### System Improvements
* **Faster Updates**: Optimized how we retrieve data from Planning Center for better performance
* **Documentation**: Updated help resources with more accurate information
### Performance Improvements
* **Faster Data Sync**: Significantly improved synchronization speed for large datasets
* **Check-ins Locations**: Fixed an issue where location information wasn't displaying correctly
* **System Reliability**: Enhanced overall platform stability and performance
### Documentation
* **New Help Center**: Launched comprehensive documentation site with improved navigation
* **Getting Started Guide**: Added step-by-step tutorials for new users
### Bug Fixes
* **Large Dataset Handling**: Resolved timeout issues when syncing large amounts of data
* **Data Accuracy**: Fixed an issue where some date fields were showing incorrect values
* **Sync Reliability**: Improved handling of data synchronization for better consistency
### Data Management
* **People Data**: Corrected an issue affecting people data synchronization
* **Field Display**: Fixed field configuration issues that affected how data was displayed
* **Performance**: Streamlined data processing for faster updates
### Reliability & Data Quality
* **Data Validation**: Improved validation to ensure data accuracy across all modules
* **System Performance**: Enhanced processing speed for better user experience
* **Giving Data**: Fixed an issue with donation data relationships
### New Features
* **Groups Support**: Added full support for Groups data synchronization
* **People Lists**: Introduced People Lists functionality for better organization
* **Multi-Campus**: Enhanced support for churches with multiple campuses
### Platform Enhancements
* **Security**: Improved data security with enhanced access controls
* **Multi-Church Support**: Better support for organizations managing multiple churches
* **Performance**: Faster data queries and improved response times
### Initial Release
* **Planning Center Integration**: Connect your Planning Center account to sync People, Giving, Calendar, and Check-ins data
* **SQL Access**: Query your church data directly using standard SQL
* **BI Tool Support**: Connect Power BI, Tableau, and other analytics tools
* **Automatic Sync**: Daily data synchronization keeps your information up-to-date
# Giving Automation Recipes
Source: https://docs.getparable.io/guides/giving-engagement/automation-recipes
Ready-to-use PCO List + Automation recipes powered by Parable's giving engagement fields.
These recipes use Parable's giving engagement fields (Giving Pattern, Giving Trend, Payment Alert) synced to the **Parable: Giving** tab in Planning Center People. Each recipe pairs a PCO List with a PCO Automation for hands-free follow-up.
## Prerequisites
Before setting up automations, confirm that:
* Engagement scoring is enabled under **Settings → Engagement**
* **Giving Engagement** sync is turned on under **Settings → Engagement → Planning Center Sync**
* The **Parable: Giving** tab in PCO People contains **Giving Pattern**, **Giving Trend**, and **Payment Alert** fields
Parable creates the tab and its fields automatically on the first sync after you enable the toggle.
The **Parable: Giving** tab fields used by these recipes:
| Field | Type | Values |
| ------------------ | ---------- | -------------------------------------------------------------------------------------------------- |
| **Giving Pattern** | Select | New Donor, Second Gift, New Recurring, Recurring, Occasional, At-Risk, Lapsed, Recovered, Inactive |
| **Giving Trend** | Select | Growing, Stable, Declining |
| **Payment Alert** | Checkboxes | Failed Gift, Expiring Payment Method, Card Fee Savings |
***
## 1. New Donor Welcome
**Goal:** Welcome first-time givers within 48 hours.
### PCO List
* **Rule:** Parable: Giving > Giving Pattern *is* "New Donor"
* **Automations tab > Run automation:** New Donor Welcome
### PCO Automation
| Step | Type | Details |
| ---- | --------- | --------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Wait** | 1 day |
| 2 | **Email** | Subject: "Thank you for your generous gift!" — personalized welcome, introduce giving impact, link to recurring giving page |
| 3 | **Wait** | 5 days |
| 4 | **Email** | Subject: "Here's how your gift made a difference" — share a story or impact metric |
***
## 2. Second Gift Follow-Up
**Goal:** Encourage the critical second-to-third gift transition.
### PCO List
* **Rule:** Parable: Giving > Giving Pattern *is* "Second Gift"
### PCO Automation
| Step | Type | Details |
| ---- | --------- | ------------------------------------------------------------------------------------------------------------ |
| 1 | **Wait** | 1 day |
| 2 | **Email** | Subject: "You're making a real difference" — thank them for continued generosity, introduce recurring giving |
| 3 | **Wait** | 14 days |
| 4 | **Email** | Subject: "Join our community of regular givers" — benefits of recurring giving, simple setup link |
***
## 3. New Recurring Celebration
**Goal:** Celebrate and reinforce the decision to set up recurring giving.
### PCO List
* **Rule:** Parable: Giving > Giving Pattern *is* "New Recurring"
### PCO Automation
| Step | Type | Details |
| ---- | --------- | ------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Wait** | 1 day |
| 2 | **Email** | Subject: "You just made generosity effortless" — celebrate their commitment, show the annualized impact of their recurring gift |
| 3 | **Wait** | 30 days |
| 4 | **Email** | Subject: "Your first month of recurring giving" — recap what their giving enabled, keep vision in front of them |
***
## 4. Build Recurring
**Goal:** Convert occasional givers into recurring donors.
### PCO List
* **Rule:** Parable: Giving > Giving Pattern *is* "Occasional"
* **AND** Parable: Giving > Giving Trend *is* "Growing" OR "Stable"
### PCO Automation
| Step | Type | Details |
| ---- | --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Email** | Subject: "Make your impact automatic" — show their giving history, explain how recurring simplifies giving |
| 2 | **Wait** | 7 days |
| 3 | **Email** | Subject: "Set it and forget it — recurring giving in 60 seconds" — direct link to set up recurring, testimonial from a recurring giver |
***
## 5. At-Risk Outreach
**Goal:** Re-engage donors who are approaching their lapse threshold before they fully disengage.
### PCO List
* **Rule:** Parable: Giving > Giving Pattern *is* "At-Risk"
### PCO Automation
| Step | Type | Details |
| ---- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
| 1 | **Email** | Subject: "We're grateful for you" — warm check-in, share a recent win or story, soft reminder of giving link |
| 2 | **Wait** | 7 days |
| 3 | **Create a task** | Assign to pastoral care: "Personal outreach to at-risk donor — check in before they lapse" |
***
## 6. Re-engage Lapsed
**Goal:** Bring back donors who have missed their expected giving cycle.
### PCO List
* **Rule:** Parable: Giving > Giving Pattern *is* "Lapsed"
### PCO Automation
| Step | Type | Details |
| ---- | ----------------- | ------------------------------------------------------------------------------------------------------ |
| 1 | **Email** | Subject: "We miss you" — gentle, non-guilt-based reminder of community impact |
| 2 | **Wait** | 10 days |
| 3 | **Email** | Subject: "Your gift matters more than you know" — share a current need or project, include giving link |
| 4 | **Wait** | 14 days |
| 5 | **Create a task** | Assign to pastoral care: "Personal outreach to lapsed donor — check in on wellbeing" |
***
## 7. Welcome Back Recovered
**Goal:** Celebrate and retain donors who returned after a lapse.
### PCO List
* **Rule:** Parable: Giving > Giving Pattern *is* "Recovered"
### PCO Automation
| Step | Type | Details |
| ---- | --------- | ------------------------------------------------------------------------------------------------------- |
| 1 | **Wait** | 1 day |
| 2 | **Email** | Subject: "Welcome back — your generosity is a blessing" — warm, celebratory tone, no mention of the gap |
| 3 | **Wait** | 21 days |
| 4 | **Email** | Subject: "Keep the momentum going" — invite to set up recurring giving |
***
## 8. Failed Gift Recovery
**Goal:** Quickly resolve failed payments before donors churn.
### PCO List
* **Rule:** Parable: Giving > Payment Alert *contains* "Failed Gift"
### PCO Automation
| Step | Type | Details |
| ---- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | **Email** | Subject: "Quick update on your recent gift" — explain payment didn't process, provide link to update payment method, empathetic tone |
| 2 | **Wait** | 3 days |
| 3 | **Email** | Subject: "We're here to help" — offer phone/email support to resolve, include direct contact for finance team |
| 4 | **Wait** | 7 days |
| 5 | **Create a task** | Assign to admin: "Follow up on unresolved failed gift — personal outreach" |
***
## 9. Expiring Payment Method
**Goal:** Proactively update payment methods before they expire and cause failed gifts.
### PCO List
* **Rule:** Parable: Giving > Payment Alert *contains* "Expiring Payment Method"
### PCO Automation
| Step | Type | Details |
| ---- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
| 1 | **Email** | Subject: "Your payment method is expiring soon" — friendly heads-up, direct link to update payment info |
| 2 | **Wait** | 7 days |
| 3 | **Email** | Subject: "Friendly reminder: update your payment method" — emphasize uninterrupted giving, step-by-step instructions |
| 4 | **Wait** | 14 days |
| 5 | **Create a task** | Assign to admin: "Expiring payment method not yet updated — personal outreach" |
***
## 10. Reduce Processing Fees (Card to ACH)
**Goal:** Encourage card-based recurring donors to switch to ACH to reduce processing fees.
### PCO List
* **Rule:** Parable: Giving > Payment Alert *contains* "Card Fee Savings"
### PCO Automation
| Step | Type | Details |
| ---- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Email** | Subject: "A simple way to maximize your giving" — explain that switching from card to bank transfer (ACH) means lower fees and more of their gift goes to mission |
| 2 | **Wait** | 14 days |
| 3 | **Email** | Subject: "Did you know? Bank transfers save on fees" — include specific savings estimate (e.g., "switching saves \~2.5% per gift"), provide direct link to update payment method |
***
## Tips for Success
**Start small.** Test each automation with a small group before rolling out to your full congregation.
* **Timing:** Run automations during business hours for best open rates
* **Tone:** Always people-first — never guilt-based or transactional
* **Personalization:** Use PCO merge fields (first name, last gift amount) where available
* **Exit conditions:** People are automatically removed from automations when their Giving Pattern changes and they no longer match the list rule
* **Coordination:** Check that automations don't overlap — a person should only be in one giving automation at a time
# Giving Stage Reference
Source: https://docs.getparable.io/guides/giving-engagement/giving-stages
Understand how Parable classifies donor behavior into giving patterns, trends, and payment alerts.
Use this guide to understand how Parable classifies donor behavior. These patterns appear on the **Parable: Giving** tab in Planning Center People — once **Giving Engagement** sync is enabled under **Settings → Engagement** — and power automation recipes, reports, and AI insights.
## Pattern Overview
Parable's model is adaptive, not fixed-day-only. For repeat donors, Parable learns cadence from the median gap between gifts and treats **two missed cycles** as the lapse threshold. ACH-backed recurring donors get a small grace buffer to account for pending timing.
| Pattern | Definition | What to Watch |
| ----------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| **New Donor** | First-ever successful gift | Warm follow-up matters because many first gifts do not become habits |
| **Second Gift** | Second-ever successful gift | This is the highest-leverage moment to reinforce momentum |
| **New Recurring** | Recently started a recurring giving schedule (first 1–2 recurring gifts) | Celebrate and reinforce the commitment before it becomes routine |
| **Recurring** | Giving is consistent or scheduled, with an established rhythm | Celebrate faithfulness and keep vision in front of them |
| **Occasional** | Gives more than once, but without a steady rhythm | Invite toward consistency without pressure |
| **At-Risk** | Previously active or recurring, but approaching the adaptive lapse threshold | Reach out early — this is the window before disconnection hardens |
| **Lapsed** | Previously active or recurring, but now past the adaptive lapse threshold | Reach out with curiosity before disconnection hardens |
| **Recovered** | Returned after a lapse and is still in the first 1–2 gifts back | Celebrate the reconnection and learn what helped |
| **Inactive** | No successful giving in 12+ months | Indicates full disengagement from financial partnership |
## Trend Reference
Trends describe the direction of a donor's giving over time. Use trend language alongside the pattern:
* **Growing** — Trailing 12-month giving is meaningfully above the prior 12-month period
* **Stable** — Trailing 12-month giving is roughly flat year over year
* **Declining** — Trailing 12-month giving is meaningfully below the prior 12-month period
## Payment Alerts
Payment alerts are operational flags — not pastoral labels. More than one can be active at once.
| Alert | Meaning |
| --------------------------- | ------------------------------------------------------------------------- |
| **Failed Gift** | A recent gift attempt failed and needs follow-up |
| **Expiring Payment Method** | The active recurring payment method is nearing expiration |
| **Card Fee Savings** | The donor is recurring on card and could be invited to ACH to reduce fees |
## Communication Guidelines
Keep the language people-focused and factual. Never shame or pressure — frame every insight as an opportunity to notice, care, and encourage next steps.
* Pair pattern language with action language: *"Second Gift donors should receive a personal thank-you and a recurring invitation."*
* Use adaptive phrasing instead of rigid day buckets: *"They are past their normal giving rhythm"* is better than *"They crossed day 121."*
* Reference specific numbers when available: *"12 donors are currently in a recovered window after returning to give again."*
# Welcome to Parable
Source: https://docs.getparable.io/index
Turn your church data into ministry insight
## Turn Church Data Into Ministry Insight
Parable connects to Planning Center, syncs your ministry data into one unified
warehouse, and gives your team dashboards, reports, engagement scoring, and a
data assistant on top of it — plus direct SQL access when you want to go
further.
**Your data tells a story. Let us help you discover it.**
Connect Planning Center, start your free trial, and get your first insights
## Why Parable Exists
Church data is fragmented by design. People, giving, attendance, groups,
serving, and events each live in their own Planning Center app, and answering a
simple question — *who came once and never came back?* — means exporting
spreadsheets and reconciling them by hand.
Parable unifies all of it, keeps it current, and puts real analysis on top.
## What You Get
Every Planning Center app you connect, synced into one queryable warehouse
Build, schedule, share, and export reports and dashboards without writing SQL
A per-person engagement score, tier, and warning light across seven ministry
categories
Ask questions in plain language and get answers, charts, and reports back
A built-in SQL editor plus a read-only connection string for your own tools
Parable keeps your data current in the background — no manual exports
## Planning Center Integration
Connect any combination of the eight Planning Center apps:
Membership records and donation history unified for whole-person insight
Attendance patterns and community involvement in one queryable source
Facility scheduling and worship planning alongside member engagement
Online content reach and event signups connected to your people data
Parable can also **write back** to Planning Center, adding each person's
engagement tier, warning light, and giving pattern as custom fields your staff
can see and filter on inside Planning Center itself.
## Working With Your Data
People profiles, dashboards, saved reports, and a SQL editor with schema
autocomplete
A read-only PostgreSQL connection string works with Power BI, Tableau,
Metabase, or any SQL client
Email reports on a schedule, publish dashboards by link, and export to PDF
or CSV
See how the engagement score, tiers, and warning lights are calculated
## Early Access Community
Parable is in early access, and we're building alongside church leaders who
understand the power of data-informed ministry.
Connect with other church data leaders, share queries, and help shape
Parable's future
Get personal help from our founder
## Your Ministry's Data Story Awaits
Every data point represents a person in your congregation — their journey, their
engagement, their growth. Parable turns those individual stories into insight
that helps you shepherd more effectively.
**Ready to start?** Follow the [quickstart guide](/quickstart), or email
[michael@getparable.io](mailto:michael@getparable.io) with questions about
your church's data.
# Data Architecture
Source: https://docs.getparable.io/planning-center/architecture
Cross-module overview of how Planning Center data is organized and interconnected in Parable
## Overview
Parable integrates with **8 Planning Center modules**, each representing a different aspect of church management. This page provides a high-level view of how these modules connect and share data.
## Module Overview
[Open diagram in new tab →](/diagrams/planning-center/architecture-01.svg)
## Module Interconnections
### People as the Central Hub
The **People module** is the foundation of the entire system. Nearly every other module references people in various roles:
* **Giving**: People as donors, joint givers, and batch creators
* **Groups**: People as group members, leaders, and owners
* **Check-ins**: People checking in to events
* **Services**: People as team members, leaders, and plan assignees
* **Calendar**: People as event creators and resource bookers
* **Registrations**: People as registrants and attendees
* **Publishing**: People as speakers and content creators
**Key Principles:**
1. **Sequential Processing**: Workflows execute sequentially to respect dependencies (People before Giving, etc.)
2. **Bulk Operations**: Data is collected and inserted in batches for performance
3. **Multi-tenant Isolation**: Row-level security ensures tenant data separation
4. **Heartbeat Pattern**: Long-running activities send heartbeats to prevent timeouts
## Database Schema Organization
All Planning Center data is stored in the `planning_center` schema with prefixed table names:
```
planning_center.{module}_{entity}
```
### Examples
* `planning_center.people_people` - People entities
* `planning_center.people_households` - Household entities
* `planning_center.giving_donations` - Donation entities
* `planning_center.groups_groups` - Group entities
* `planning_center.services_plans` - Service plan entities
### Common Patterns Across All Modules
Every table follows these patterns:
#### System Fields
All tables include:
* `system_status` - All tables have a `system_status` column that filters to only show `active` records
* `system_created_at` - When record was first created
* `system_updated_at` - Last modification timestamp
* `tenant_organization_id` - All tables have a `tenant_organization_id` column that filters to only show records for the current organization
#### Entity ID Pattern
All entities store their Planning Center ID:
* `{entity}_id` - Original Planning Center identifier (e.g., `people_id`, `donation_id`)
#### Row Level Security (RLS)
All tables have RLS policies that automatically:
* Filter data by current database role
* Ensure tenant isolation
* Restrict access to `system_status = 'active'` records
## Module Details
### People Module (98 tables)
**Core Entities:**
* People, Households, Household Memberships
* Addresses, Emails, Phone Numbers
* Campuses, Organizations
* Forms, Form Submissions
* Workflows, Workflow Cards
* Lists, Notes, Messages
**Complexity**: Highest - manages the foundational identity layer
**Key Relationship Pattern**: Heavy use of relationship tables for flexible associations
### Giving Module (27 tables)
**Core Entities:**
* Donations, Batches, Batch Groups
* Pledges, Pledge Campaigns
* Recurring Donations, Refunds
* Designations, Funds
* Payment Methods, Payment Sources
**Dependencies**: Requires People (donors), Campuses
**Key Feature**: Financial transaction tracking with audit trails
### Groups Module (28 tables)
**Core Entities:**
* Groups, Group Types
* Memberships, Enrollments
* Events, Event Notes, Attendance
* Locations, Resources, Tags
**Dependencies**: Requires People (members, leaders)
**Key Feature**: Community organization and event management
### Check-ins Module (41 tables)
**Core Entities:**
* Check-ins, Check-in Groups
* Events, Event Times, Event Periods
* Locations, Location Labels
* Person Events, Attendance Types
* Stations, Passes, Themes
**Dependencies**: Requires People (attendees), Events
**Key Feature**: Event attendance tracking with label printing
### Services Module (54 tables)
**Core Entities:**
* Service Types, Plans, Plan Times
* Teams, Team Positions
* Songs, Arrangements, Keys
* Items, Media, Schedules
* Blockouts, Signup Sheets
**Dependencies**: Requires People (team members), potentially Groups
**Key Feature**: Complex worship service planning
### Calendar Module (42 tables)
**Core Entities:**
* Events, Event Instances, Event Times
* Resources, Resource Bookings
* Room Setups, Resource Questions
* Conflicts, Attachments, Feeds
**Dependencies**: Integrates with Groups, Services, Check-ins events
**Key Feature**: Centralized event and resource management
### Registrations Module (14 tables)
**Core Entities:**
* Registrations, Signups
* Attendees, Emergency Contacts
* Categories, Selection Types
* Locations, Campuses
**Dependencies**: Requires People (registrants)
**Key Feature**: Event registration and attendee management
### Publishing Module (18 tables)
**Core Entities:**
* Channels, Channel Times
* Episodes, Episode Times, Episode Resources
* Series, Speakers, Speakerships
* Note Templates
**Dependencies**: Requires People (speakers), potentially Services (sermons)
**Key Feature**: Sermon and content publishing workflow
## Performance Characteristics
### Table Sizes (Approximate)
Based on typical church databases:
| Module | Tables | Est. Rows (Small Church) | Est. Rows (Large Church) |
| ------------- | ------ | ------------------------ | ------------------------ |
| People | 84 | 10K - 50K | 500K - 2M |
| Giving | 24 | 5K - 20K | 200K - 1M |
| Groups | 25 | 1K - 5K | 50K - 200K |
| Check-ins | 26 | 5K - 20K | 100K - 500K |
| Services | 55+ | 2K - 10K | 50K - 200K |
| Calendar | 27 | 1K - 5K | 20K - 100K |
| Registrations | 14 | 500 - 2K | 10K - 50K |
| Publishing | 15 | 100 - 1K | 5K - 20K |
### Sync Performance
* **Full sync time**: 5 minutes - 2 hours (depends on data size)
* **Typical interval**: Every 24 hours
* **Bulk insert performance**: 1K-10K rows/second per table
## Multi-Tenant Architecture
### Tenant Isolation
[Open diagram in new tab →](/diagrams/planning-center/architecture-02.svg)
**Key Security Features:**
1. **Role-based access**: Each tenant has a dedicated database role
2. **Automatic filtering**: Views filter by `CURRENT_ROLE` to show only tenant's data
3. **No cross-tenant queries**: Impossible to access other tenants' data
4. **Schema-level isolation**: All Planning Center data in dedicated schema
## Common Query Patterns
### Cross-Module Queries
**Example: Find all donations from a specific group's members**
```sql theme={null}
-- Get all donations from small group members
WITH group_members AS (
SELECT DISTINCT mr.relationship_id as person_id
FROM planning_center.groups_memberships m
JOIN planning_center.groups_memberships_relationships mr
ON mr.membership_id = m.membership_id AND mr.relationship_type = 'Person'
JOIN planning_center.groups_memberships_relationships mgr
ON mgr.membership_id = m.membership_id AND mgr.relationship_type = 'Group'
WHERE mgr.relationship_id = 'group_123'
)
SELECT
p.first_name,
p.last_name,
d.amount_cents / 100.0 AS amount,
d.received_at,
f.name AS fund_name
FROM group_members gm
JOIN planning_center.people_people p
ON gm.person_id = p.person_id
JOIN planning_center.giving_donations_relationships dr
ON dr.relationship_id = p.person_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d
ON d.donation_id = dr.donation_id
JOIN planning_center.giving_donations_relationships ddr
ON ddr.donation_id = d.donation_id
AND ddr.relationship_type = 'Designation'
JOIN planning_center.giving_designations des
ON des.designation_id = ddr.relationship_id
JOIN planning_center.giving_designations_relationships desr
ON desr.designation_id = des.designation_id
AND desr.relationship_type = 'Fund'
JOIN planning_center.giving_funds f
ON f.fund_id = desr.relationship_id
WHERE d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
ORDER BY d.received_at DESC;
```
**Example: Find people who are both group leaders and service team members**
```sql theme={null}
-- Find people serving in both groups and services
WITH group_leaders AS (
SELECT DISTINCT mr.relationship_id as person_id
FROM planning_center.groups_memberships m
JOIN planning_center.groups_memberships_relationships mr
ON mr.membership_id = m.membership_id AND mr.relationship_type = 'Person'
WHERE m.role = 'leader'
),
service_team AS (
SELECT DISTINCT pp.person_id
FROM planning_center.services_plan_people pp
WHERE pp.status = 'C' -- Confirmed
)
SELECT
p.first_name,
p.last_name,
COUNT(DISTINCT mgr.relationship_id) AS groups_led,
COUNT(DISTINCT pp.plan_id) AS services_on
FROM planning_center.people_people p
JOIN group_leaders gl ON p.person_id = gl.person_id
JOIN service_team st ON p.person_id = st.person_id
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_type = 'Person' AND mr.relationship_id = p.person_id
LEFT JOIN planning_center.groups_memberships gm
ON gm.membership_id = mr.membership_id AND gm.role = 'leader'
LEFT JOIN planning_center.groups_memberships_relationships mgr
ON mgr.membership_id = gm.membership_id AND mgr.relationship_type = 'Group'
LEFT JOIN planning_center.services_plan_people pp
ON pp.person_id = p.person_id
GROUP BY p.person_id, p.first_name, p.last_name
ORDER BY groups_led DESC, services_on DESC;
```
## Next Steps
Explore specific module data models:
Core identity and household management
Donations, pledges, and financial tracking
Small groups and community organization
Event attendance and check-in tracking
Worship service planning and teams
Event scheduling and resource booking
Event registration and signups
Sermon and content publishing
## Additional Resources
Query the Planning Center data via API
# Advanced Planning Center Calendar Queries
Source: https://docs.getparable.io/planning-center/calendar/advanced-queries
Advanced Calendar SQL for churches: detect scheduling conflicts, measure room utilization over time, and optimize how resources are allocated.
Master complex scheduling scenarios, conflict detection, and resource optimization with these advanced SQL patterns for your church calendar.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Calendar module live in the `planning_center` schema. Always prefix table names with `planning_center.` in every query.
✅ CORRECT: `SELECT * FROM planning_center.calendar_event_instances`
❌ INCORRECT: `SELECT * FROM calendar_event_instances`
### Row Level Security (RLS)
Row Level Security automatically handles:
* **tenant\_organization\_id** – restricts results to your organization
* **system\_status** – returns active records by default
**Do not add these filters manually**—RLS already enforces them and redundant predicates can hide data or hurt performance:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus on scheduling, resource, and approval logic while trusting RLS to manage tenancy and status.
## Table of Contents
* [Conflict Detection](#conflict-detection)
* [Resource Optimization](#resource-optimization)
* [Complex Scheduling Patterns](#complex-scheduling-patterns)
* [Utilization Analytics](#utilization-analytics)
* [Approval Workflows](#approval-workflows)
* [Recurring Event Management](#recurring-event-management)
* [Performance Optimization](#performance-optimization)
## Conflict Detection
### Find Double-Booked Resources
```sql theme={null}
-- Detect resources with overlapping bookings
WITH booking_conflicts AS (
SELECT
rb1.resource_booking_id as booking1_id,
rb2.resource_booking_id as booking2_id,
r.name as resource_name,
r.kind as resource_type,
rb1.starts_at as booking1_start,
rb1.ends_at as booking1_end,
rb2.starts_at as booking2_start,
rb2.ends_at as booking2_end,
e1.name as event1_name,
e2.name as event2_name
FROM planning_center.calendar_resource_bookings rb1
JOIN planning_center.calendar_resource_bookings_relationships rbr1_res
ON rbr1_res.resource_booking_id = rb1.resource_booking_id AND rbr1_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resource_bookings rb2
ON rb2.resource_booking_id != rb1.resource_booking_id
JOIN planning_center.calendar_resource_bookings_relationships rbr2_res
ON rbr2_res.resource_booking_id = rb2.resource_booking_id AND rbr2_res.relationship_type = 'Resource'
AND rbr2_res.relationship_id = rbr1_res.relationship_id -- Same resource
JOIN planning_center.calendar_resources r
ON r.resource_id = rbr1_res.relationship_id
JOIN planning_center.calendar_resource_bookings_relationships rbr1_ei
ON rbr1_ei.resource_booking_id = rb1.resource_booking_id AND rbr1_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei1
ON ei1.event_instance_id = rbr1_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir1
ON eir1.event_instance_id = ei1.event_instance_id AND eir1.relationship_type = 'Event'
JOIN planning_center.calendar_events e1 ON e1.event_id = eir1.relationship_id
JOIN planning_center.calendar_resource_bookings_relationships rbr2_ei
ON rbr2_ei.resource_booking_id = rb2.resource_booking_id AND rbr2_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei2
ON ei2.event_instance_id = rbr2_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir2
ON eir2.event_instance_id = ei2.event_instance_id AND eir2.relationship_type = 'Event'
JOIN planning_center.calendar_events e2 ON e2.event_id = eir2.relationship_id
WHERE rb1.resource_booking_id < rb2.resource_booking_id -- Avoid duplicates
AND rb1.starts_at < rb2.ends_at -- Overlap condition
AND rb1.ends_at > rb2.starts_at
AND rb1.starts_at >= CURRENT_DATE -- Only future conflicts
)
SELECT
resource_name,
resource_type,
event1_name,
booking1_start,
booking1_end,
event2_name,
booking2_start,
booking2_end,
ROUND(
EXTRACT(EPOCH FROM (
LEAST(booking1_end, booking2_end) -
GREATEST(booking1_start, booking2_start)
))/3600, 1
) as overlap_hours
FROM booking_conflicts
ORDER BY booking1_start, resource_name;
```
### Capacity Violations
```sql theme={null}
-- Find bookings exceeding resource capacity
WITH resource_capacity_check AS (
SELECT
rb.starts_at,
rb.ends_at,
r.resource_id,
r.name as resource_name,
r.quantity as max_capacity,
SUM(rb.quantity) OVER (
PARTITION BY r.resource_id, rb.starts_at
ORDER BY rb.starts_at
) as total_booked,
e.name as event_name
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr_res
ON rbr_res.resource_booking_id = rb.resource_booking_id AND rbr_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr_res.relationship_id
JOIN planning_center.calendar_resource_bookings_relationships rbr_ei
ON rbr_ei.resource_booking_id = rb.resource_booking_id AND rbr_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei ON ei.event_instance_id = rbr_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE rb.starts_at >= CURRENT_DATE
)
SELECT
resource_name,
max_capacity,
total_booked,
total_booked - max_capacity as overbooked_by,
starts_at,
event_name
FROM resource_capacity_check
WHERE total_booked > max_capacity
ORDER BY starts_at, resource_name;
```
### Time Buffer Violations
```sql theme={null}
-- Find back-to-back bookings without buffer time
WITH sequential_bookings AS (
SELECT
r.name as resource_name,
e1.name as first_event,
rb1.ends_at as first_ends,
e2.name as second_event,
rb2.starts_at as second_starts,
ROUND(
EXTRACT(EPOCH FROM (rb2.starts_at - rb1.ends_at))/60, 1
) as gap_minutes
FROM planning_center.calendar_resource_bookings rb1
JOIN planning_center.calendar_resource_bookings_relationships rbr1_res
ON rbr1_res.resource_booking_id = rb1.resource_booking_id AND rbr1_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr1_res.relationship_id
JOIN planning_center.calendar_resource_bookings rb2
ON rb2.resource_booking_id != rb1.resource_booking_id
AND rb2.starts_at >= rb1.ends_at
AND rb2.starts_at < rb1.ends_at + INTERVAL '30 minutes' -- Within 30 min
JOIN planning_center.calendar_resource_bookings_relationships rbr2_res
ON rbr2_res.resource_booking_id = rb2.resource_booking_id AND rbr2_res.relationship_type = 'Resource'
AND rbr2_res.relationship_id = rbr1_res.relationship_id -- Same resource
JOIN planning_center.calendar_resource_bookings_relationships rbr1_ei
ON rbr1_ei.resource_booking_id = rb1.resource_booking_id AND rbr1_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei1 ON ei1.event_instance_id = rbr1_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir1
ON eir1.event_instance_id = ei1.event_instance_id AND eir1.relationship_type = 'Event'
JOIN planning_center.calendar_events e1 ON e1.event_id = eir1.relationship_id
JOIN planning_center.calendar_resource_bookings_relationships rbr2_ei
ON rbr2_ei.resource_booking_id = rb2.resource_booking_id AND rbr2_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei2 ON ei2.event_instance_id = rbr2_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir2
ON eir2.event_instance_id = ei2.event_instance_id AND eir2.relationship_type = 'Event'
JOIN planning_center.calendar_events e2 ON e2.event_id = eir2.relationship_id
WHERE rb1.starts_at >= CURRENT_DATE
)
SELECT *
FROM sequential_bookings
WHERE gap_minutes < 15 -- Less than 15 minute buffer
ORDER BY first_ends;
```
## Resource Optimization
### Underutilized Resources
```sql theme={null}
-- Find resources rarely used
WITH resource_usage AS (
SELECT
r.resource_id,
r.name,
r.kind,
COUNT(rb.resource_booking_id) as booking_count,
COALESCE(
SUM(EXTRACT(EPOCH FROM (rb.ends_at - rb.starts_at))/3600),
0
) as total_hours_booked,
-- Only consider past bookings for "last used" date
MAX(CASE
WHEN rb.starts_at <= CURRENT_DATE THEN rb.starts_at
ELSE NULL
END) as last_booking_date,
-- Track future bookings separately
MIN(CASE
WHEN rb.starts_at > CURRENT_DATE THEN rb.starts_at
ELSE NULL
END) as next_booking_date
FROM planning_center.calendar_resources r
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.relationship_id = r.resource_id AND rbr.relationship_type = 'Resource'
LEFT JOIN planning_center.calendar_resource_bookings rb
ON rb.resource_booking_id = rbr.resource_booking_id
AND rb.starts_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY r.resource_id, r.name, r.kind
),
usage_stats AS (
SELECT
*,
total_hours_booked / (90 * 24.0) * 100 as utilization_percentage,
CASE
WHEN last_booking_date IS NOT NULL
THEN CURRENT_DATE - last_booking_date::date
ELSE NULL
END as days_since_last_booking
FROM resource_usage
)
SELECT
name,
kind,
booking_count,
ROUND(total_hours_booked, 2) as hours_used_90_days,
ROUND(utilization_percentage, 2) as utilization_pct,
CASE
WHEN days_since_last_booking IS NOT NULL
THEN days_since_last_booking::text || ' days ago'
WHEN next_booking_date IS NOT NULL
THEN 'Scheduled for ' || next_booking_date::date::text
ELSE 'Never booked'
END as last_activity
FROM usage_stats
WHERE utilization_percentage < 10 -- Less than 10% utilized
OR booking_count < 5 -- Or rarely booked
ORDER BY utilization_percentage;
```
### Peak Usage Times
```sql theme={null}
-- Identify when resources are most in demand
WITH hourly_usage AS (
SELECT
EXTRACT(DOW FROM rb.starts_at) as day_of_week,
EXTRACT(HOUR FROM rb.starts_at) as hour_of_day,
TO_CHAR(rb.starts_at, 'FMDay') as day_name,
COUNT(DISTINCT rb.resource_booking_id) as bookings,
COUNT(DISTINCT rbr.relationship_id) as unique_resources
FROM planning_center.calendar_resource_bookings rb
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
WHERE rb.starts_at >= CURRENT_DATE - INTERVAL '180 days'
GROUP BY
EXTRACT(DOW FROM rb.starts_at),
EXTRACT(HOUR FROM rb.starts_at),
TO_CHAR(rb.starts_at, 'FMDay')
)
SELECT
day_name,
hour_of_day || ':00' as time_slot,
bookings,
unique_resources,
REPEAT('█', (bookings::float / MAX(bookings) OVER () * 20)::int) as usage_bar
FROM hourly_usage
WHERE bookings > 0
ORDER BY day_of_week, hour_of_day;
```
### Optimal Resource Allocation
```sql theme={null}
-- Suggest resource reassignments based on usage patterns
WITH resource_demand AS (
SELECT
DATE_TRUNC('week', rb.starts_at) as week,
r.kind,
COUNT(DISTINCT rb.resource_booking_id) as bookings,
COUNT(DISTINCT r.resource_id) as resources_used,
SUM(rb.quantity) as total_quantity_requested
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr.relationship_id
WHERE rb.starts_at >= CURRENT_DATE - INTERVAL '12 weeks'
GROUP BY DATE_TRUNC('week', rb.starts_at), r.kind
),
resource_availability AS (
SELECT
kind,
COUNT(*) as total_resources,
SUM(quantity) as total_capacity
FROM planning_center.calendar_resources
GROUP BY kind
)
SELECT
rd.kind as resource_type,
ROUND(AVG(rd.bookings)::numeric, 2) as avg_weekly_bookings,
ROUND(AVG(rd.resources_used)::numeric, 2) as avg_resources_used,
ra.total_resources as available_resources,
ROUND(AVG(rd.resources_used) * 100.0 / ra.total_resources, 2) as utilization_rate,
CASE
WHEN AVG(rd.resources_used) * 100.0 / ra.total_resources > 80 THEN 'High Demand - Consider adding resources'
WHEN AVG(rd.resources_used) * 100.0 / ra.total_resources < 30 THEN 'Low Demand - Consider consolidating'
ELSE 'Balanced'
END as recommendation
FROM resource_demand rd
JOIN resource_availability ra ON rd.kind = ra.kind
GROUP BY rd.kind, ra.total_resources, ra.total_capacity
ORDER BY utilization_rate DESC;
```
## Complex Scheduling Patterns
### Multi-Resource Event Requirements
```sql theme={null}
-- Events requiring multiple resources simultaneously
WITH event_resource_summary AS (
SELECT
e.event_id,
e.name as event_name,
ei.starts_at,
ei.ends_at,
COUNT(DISTINCT rbr_res.relationship_id) as resource_count,
STRING_AGG(DISTINCT r.name || ' (' || r.kind || ')', ', ' ORDER BY r.name || ' (' || r.kind || ')') as resources_needed,
SUM(rb.quantity) as total_quantity
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
JOIN planning_center.calendar_resource_bookings_relationships rbr_ei
ON rbr_ei.relationship_id = ei.event_instance_id AND rbr_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_resource_bookings rb ON rb.resource_booking_id = rbr_ei.resource_booking_id
JOIN planning_center.calendar_resource_bookings_relationships rbr_res
ON rbr_res.resource_booking_id = rb.resource_booking_id AND rbr_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr_res.relationship_id
WHERE ei.starts_at >= CURRENT_DATE
GROUP BY e.event_id, e.name, ei.event_instance_id, ei.starts_at, ei.ends_at
HAVING COUNT(DISTINCT rbr_res.relationship_id) > 1 -- Multiple resources
)
SELECT
event_name,
starts_at,
resource_count,
resources_needed,
total_quantity
FROM event_resource_summary
ORDER BY starts_at, resource_count DESC;
```
### Recurring Event Pattern Analysis
```sql theme={null}
-- Analyze recurring event patterns and exceptions
WITH recurring_analysis AS (
SELECT
e.event_id,
e.name,
ei.recurrence,
ei.recurrence_description,
COUNT(*) as total_instances,
MIN(ei.starts_at) as first_occurrence,
MAX(ei.starts_at) as last_occurrence,
-- Calculate average interval between occurrences
EXTRACT(EPOCH FROM (MAX(ei.starts_at) - MIN(ei.starts_at))) /
NULLIF(COUNT(*) - 1, 0) / 86400 as avg_days_between,
-- Detect irregular patterns
STDDEV(EXTRACT(DOW FROM ei.starts_at)) as day_variance,
STDDEV(EXTRACT(HOUR FROM ei.starts_at)) as hour_variance
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.recurrence <> 'None'
GROUP BY e.event_id, e.name, ei.recurrence, ei.recurrence_description
)
SELECT
name,
recurrence_description,
total_instances,
TO_CHAR(first_occurrence, 'Mon DD, YYYY') as first_date,
TO_CHAR(last_occurrence, 'Mon DD, YYYY') as last_date,
ROUND(avg_days_between, 1) as avg_days_interval,
CASE
WHEN day_variance < 0.5 THEN 'Consistent day'
WHEN day_variance < 2 THEN 'Variable day'
ELSE 'Irregular schedule'
END as schedule_consistency,
CASE
WHEN hour_variance < 0.5 THEN 'Consistent time'
ELSE 'Variable time'
END as time_consistency
FROM recurring_analysis
ORDER BY total_instances DESC;
```
### Availability Windows
```sql theme={null}
-- Find available time slots for a resource
WITH time_slots AS (
-- Generate hourly time slots for next 7 days
SELECT
generate_series(
DATE_TRUNC('hour', CURRENT_TIMESTAMP),
DATE_TRUNC('hour', CURRENT_TIMESTAMP) + INTERVAL '7 days',
INTERVAL '1 hour'
) as slot_start
),
booked_slots AS (
-- Find already booked time slots
SELECT DISTINCT
DATE_TRUNC('hour', rb.starts_at) as booked_start,
DATE_TRUNC('hour', rb.ends_at) + INTERVAL '1 hour' as booked_end,
r.resource_id,
r.name
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr.relationship_id
WHERE r.name = 'Main Sanctuary' -- Change to desired resource
AND rb.starts_at >= CURRENT_TIMESTAMP
AND rb.starts_at < CURRENT_TIMESTAMP + INTERVAL '7 days'
),
availability AS (
SELECT
ts.slot_start,
ts.slot_start + INTERVAL '1 hour' as slot_end,
CASE
WHEN bs.booked_start IS NULL THEN 'Available'
ELSE 'Booked'
END as status
FROM time_slots ts
LEFT JOIN booked_slots bs
ON ts.slot_start >= bs.booked_start
AND ts.slot_start < bs.booked_end
WHERE EXTRACT(HOUR FROM ts.slot_start) BETWEEN 8 AND 20 -- Business hours only
)
SELECT
TO_CHAR(slot_start, 'FMDay, Mon DD') as date,
TO_CHAR(slot_start, 'HH12:MI AM') || ' - ' || TO_CHAR(slot_end, 'HH12:MI AM') as time_slot,
status
FROM availability
WHERE status = 'Available'
ORDER BY slot_start
LIMIT 20;
```
## Utilization Analytics
### Monthly Utilization Trends
```sql theme={null}
-- Track facility utilization trends over time
WITH monthly_metrics AS (
SELECT
DATE_TRUNC('month', rb.starts_at) as month,
r.kind as resource_type,
COUNT(DISTINCT rb.resource_booking_id) as total_bookings,
COUNT(DISTINCT DATE(rb.starts_at)) as days_with_bookings,
COUNT(DISTINCT r.resource_id) as unique_resources_used,
SUM(EXTRACT(EPOCH FROM (rb.ends_at - rb.starts_at))/3600) as total_hours,
AVG(EXTRACT(EPOCH FROM (rb.ends_at - rb.starts_at))/3600) as avg_booking_hours
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr.relationship_id
WHERE rb.starts_at >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', rb.starts_at), r.kind
),
with_trends AS (
SELECT
*,
LAG(total_bookings, 1) OVER (PARTITION BY resource_type ORDER BY month) as prev_month_bookings,
AVG(total_hours) OVER (
PARTITION BY resource_type
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) as three_month_avg_hours
FROM monthly_metrics
)
SELECT
TO_CHAR(month, 'Mon YYYY') as month_year,
resource_type,
total_bookings,
days_with_bookings,
ROUND(total_hours, 1) as total_hours,
ROUND(avg_booking_hours, 1) as avg_duration,
ROUND(((total_bookings - prev_month_bookings) * 100.0 / NULLIF(prev_month_bookings, 0)), 1) as month_over_month_pct,
ROUND(three_month_avg_hours, 1) as rolling_3mo_avg_hours
FROM with_trends
ORDER BY month DESC, resource_type;
```
### Cost Per Use Analysis
```sql theme={null}
-- Calculate implied cost per resource use (if costs were tracked)
WITH resource_usage_costs AS (
SELECT
r.resource_id,
r.name,
r.kind,
COUNT(rb.resource_booking_id) as times_used,
SUM(EXTRACT(EPOCH FROM (rb.ends_at - rb.starts_at))/3600) as total_hours_used,
-- Assuming some baseline costs (customize as needed)
CASE r.kind
WHEN 'Room' THEN 50 -- $50/hour for rooms
WHEN 'Resource' THEN 25 -- $25/hour for resources
ELSE 10 -- $10/hour for other
END as hourly_rate
FROM planning_center.calendar_resources r
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.relationship_id = r.resource_id AND rbr.relationship_type = 'Resource'
LEFT JOIN planning_center.calendar_resource_bookings rb
ON rb.resource_booking_id = rbr.resource_booking_id
AND rb.starts_at >= DATE_TRUNC('month', CURRENT_DATE)
GROUP BY r.resource_id, r.name, r.kind
)
SELECT
name,
kind,
times_used,
ROUND(total_hours_used, 2) as hours_used,
hourly_rate as rate_per_hour,
ROUND(total_hours_used * hourly_rate, 2) as implied_value,
CASE
WHEN times_used > 0 THEN ROUND((total_hours_used * hourly_rate) / times_used, 2)
ELSE 0
END as value_per_use
FROM resource_usage_costs
WHERE times_used > 0
ORDER BY implied_value DESC;
```
## Approval Workflows
### Pending Approvals
```sql theme={null}
-- Events awaiting approval with their resource requirements
SELECT
e.event_id,
e.name as event_name,
e.approval_status,
e.percent_approved,
e.percent_rejected,
ei.starts_at,
COUNT(DISTINCT err.event_resource_request_id) as pending_resource_requests,
STRING_AGG(DISTINCT r.name, ', ') as requested_resources
FROM planning_center.calendar_events e
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.relationship_id = e.event_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_event_instances ei ON ei.event_instance_id = eir.event_instance_id
LEFT JOIN planning_center.calendar_event_resource_requests_relationships errr
ON errr.relationship_id = e.event_id AND errr.relationship_type = 'Event'
LEFT JOIN planning_center.calendar_event_resource_requests err
ON err.event_resource_request_id = errr.event_resource_request_id
LEFT JOIN planning_center.calendar_event_resource_requests_relationships errr_res
ON errr_res.event_resource_request_id = err.event_resource_request_id AND errr_res.relationship_type = 'Resource'
LEFT JOIN planning_center.calendar_resources r
ON r.resource_id = errr_res.relationship_id
WHERE e.approval_status = 'P' -- Pending review (approval_status is never NULL)
AND ei.starts_at >= CURRENT_DATE
GROUP BY e.event_id, e.name, e.approval_status, e.percent_approved,
e.percent_rejected, ei.event_instance_id, ei.starts_at
ORDER BY ei.starts_at;
```
### Approval Response Times
```sql theme={null}
-- Analyze how quickly approvals are processed
WITH approval_metrics AS (
SELECT
e.event_id,
e.name,
e.created_at as request_time,
e.updated_at as decision_time,
e.approval_status,
EXTRACT(EPOCH FROM (e.updated_at - e.created_at))/3600 as hours_to_decision,
ei.starts_at as event_start,
EXTRACT(EPOCH FROM (ei.starts_at - e.created_at))/86400 as days_advance_notice
FROM planning_center.calendar_events e
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.relationship_id = e.event_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_event_instances ei ON ei.event_instance_id = eir.event_instance_id
WHERE e.approval_status IN ('A', 'R') -- decided events only
AND e.updated_at > e.created_at
)
SELECT
approval_status,
COUNT(*) as total_events,
ROUND(AVG(hours_to_decision), 1) as avg_hours_to_decision,
ROUND(MIN(hours_to_decision), 1) as fastest_decision,
ROUND(MAX(hours_to_decision), 1) as slowest_decision,
ROUND(AVG(days_advance_notice), 1) as avg_days_advance_notice
FROM approval_metrics
GROUP BY approval_status
ORDER BY approval_status;
```
## Recurring Event Management
### Detect Broken Recurring Patterns
```sql theme={null}
-- Find recurring events with missed occurrences
WITH expected_intervals AS (
SELECT
e.event_id,
e.name,
ei.recurrence,
-- Calculate expected interval based on recurrence type
CASE ei.recurrence
WHEN 'Weekly' THEN 7
WHEN 'Daily' THEN 1
WHEN 'Monthly' THEN 30
WHEN 'Yearly' THEN 365
ELSE NULL
END as expected_days,
ei.starts_at,
LAG(ei.starts_at) OVER (PARTITION BY e.event_id ORDER BY ei.starts_at) as prev_occurrence,
EXTRACT(EPOCH FROM (
ei.starts_at - LAG(ei.starts_at) OVER (PARTITION BY e.event_id ORDER BY ei.starts_at)
))/86400 as actual_days_gap
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.recurrence <> 'None'
)
SELECT
name,
TO_CHAR(prev_occurrence, 'Mon DD, YYYY') as previous_date,
TO_CHAR(starts_at, 'Mon DD, YYYY') as current_date,
expected_days,
ROUND(actual_days_gap, 1) as actual_days,
ROUND(actual_days_gap - expected_days, 1) as variance_days
FROM expected_intervals
WHERE expected_days IS NOT NULL
AND actual_days_gap IS NOT NULL
AND ABS(actual_days_gap - expected_days) > expected_days * 0.2 -- 20% variance
ORDER BY starts_at DESC;
```
## Performance Optimization
### Optimized Conflict Detection Query
```sql theme={null}
-- Efficient conflict detection using window functions
WITH resource_timeline AS (
SELECT
rbr_res.relationship_id as resource_id,
r.name as resource_name,
rb.starts_at,
rb.ends_at,
e.name as event_name,
-- Use window functions to find overlaps
LAG(rb.ends_at) OVER (PARTITION BY rbr_res.relationship_id ORDER BY rb.starts_at) as prev_end,
LEAD(rb.starts_at) OVER (PARTITION BY rbr_res.relationship_id ORDER BY rb.starts_at) as next_start
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr_res
ON rbr_res.resource_booking_id = rb.resource_booking_id AND rbr_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr_res.relationship_id
JOIN planning_center.calendar_resource_bookings_relationships rbr_ei
ON rbr_ei.resource_booking_id = rb.resource_booking_id AND rbr_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei ON ei.event_instance_id = rbr_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE rb.starts_at >= CURRENT_DATE
AND rb.starts_at < CURRENT_DATE + INTERVAL '30 days'
)
SELECT
resource_name,
event_name,
starts_at,
ends_at,
CASE
WHEN prev_end > starts_at THEN 'Conflict with previous'
WHEN next_start < ends_at THEN 'Conflict with next'
ELSE 'No conflict'
END as conflict_status
FROM resource_timeline
WHERE prev_end > starts_at OR next_start < ends_at
ORDER BY resource_id, starts_at;
```
## Best Practices for Advanced Calendar Queries
### 1. Use Window Functions for Sequential Analysis
Window functions are perfect for finding gaps, overlaps, and patterns in scheduling data.
### 2. Optimize Date Range Filters
Always filter by date ranges early in your query to reduce the dataset size.
### 3. Handle NULL Values in Scheduling
Some fields are never NULL even though they look optional: `approval_status` is always `'A'`, `'P'`, or `'R'`, and `recurrence` uses the literal `'None'` for non-recurring instances.
### 4. Consider Time Zones
Ensure your timestamp comparisons account for time zone differences if applicable.
### 5. Use CTEs for Complex Logic
Break down complex scheduling logic into manageable CTEs for better readability and performance.
## Next Steps
* Review [Reporting Examples](/planning-center/calendar/reporting-examples) for complete, production-ready reports
* Check the [Data Model](/planning-center/calendar/data-model) for detailed table documentation
* Return to [Basic Queries](/planning-center/calendar/basic-queries) to review fundamentals
# Basic Planning Center Calendar Queries
Source: https://docs.getparable.io/planning-center/calendar/basic-queries
Start querying Planning Center Calendar data: list upcoming events, see which rooms are in use, and check which resources are booked and when.
Start here to learn the fundamentals of querying your church's calendar and facility data. Each example builds your confidence with SQL while solving real scheduling challenges.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Calendar module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your SQL.
✅ CORRECT: `SELECT * FROM planning_center.calendar_events`
❌ INCORRECT: `SELECT * FROM calendar_events`
### Row Level Security (RLS)
Row Level Security automatically handles:
* **tenant\_organization\_id** – isolates data to your organization
* **system\_status** – returns active records by default
**Do not duplicate these filters in your queries**—RLS already enforces them and extra predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Let RLS govern tenant and status filtering so you can focus on scheduling logic and resource constraints.
## Table of Contents
* [Viewing Events](#viewing-events)
* [Event Instances and Scheduling](#event-instances-and-scheduling)
* [Resources and Rooms](#resources-and-rooms)
* [Resource Bookings](#resource-bookings)
* [Date and Time Queries](#date-and-time-queries)
* [Tags and Categories](#tags-and-categories)
## Viewing Events
### List All Events
```sql theme={null}
-- View all your church's events
SELECT
event_id,
name,
TRIM(REGEXP_REPLACE(REGEXP_REPLACE(description, '<[^>]*>', '', 'g'), '\s+', ' ', 'g')) as description,
approval_status,
visible_in_church_center,
created_at
FROM planning_center.calendar_events
ORDER BY created_at DESC
LIMIT 50;
```
### Active and Approved Events
```sql theme={null}
-- Only show approved events visible to the congregation
SELECT
event_id,
name,
TRIM(REGEXP_REPLACE(REGEXP_REPLACE(description, '<[^>]*>', '', 'g'), '\s+', ' ', 'g')) as description,
featured, -- Featured events for promotion
image_url
FROM planning_center.calendar_events
WHERE approval_status = 'A' -- A = Approved
AND visible_in_church_center = true
ORDER BY name;
```
### Search Events by Name
```sql theme={null}
-- Find events containing specific keywords
SELECT
event_id,
name,
TRIM(REGEXP_REPLACE(REGEXP_REPLACE(description, '<[^>]*>', '', 'g'), '\s+', ' ', 'g')) as description,
summary
FROM planning_center.calendar_events
WHERE LOWER(name) LIKE '%youth%'
OR LOWER(REGEXP_REPLACE(description, '<[^>]*>', '', 'g')) LIKE '%youth%'
ORDER BY name;
```
## Event Instances and Scheduling
### Upcoming Event Instances
```sql theme={null}
-- Get the next 20 scheduled events
SELECT
ei.event_instance_id,
e.name as event_name,
ei.starts_at,
ei.ends_at,
ei.location,
ei.all_day_event,
EXTRACT(EPOCH FROM (ei.ends_at - ei.starts_at))/3600 as duration_hours
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.starts_at >= CURRENT_TIMESTAMP
ORDER BY ei.starts_at
LIMIT 20;
```
### This Week's Schedule
```sql theme={null}
-- All events for the current week
SELECT
e.name,
ei.starts_at,
ei.ends_at,
ei.location,
CASE
WHEN ei.all_day_event THEN 'All Day'
ELSE TO_CHAR(ei.starts_at, 'HH12:MI AM')
END as start_time
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.starts_at >= DATE_TRUNC('week', CURRENT_DATE)
AND ei.starts_at < DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '1 week'
ORDER BY ei.starts_at;
```
### Recurring Events
```sql theme={null}
-- Find all recurring events and their patterns
SELECT
e.name,
ei.recurrence,
ei.recurrence_description,
COUNT(*) as instance_count,
MIN(ei.starts_at) as first_occurrence,
MAX(ei.starts_at) as last_occurrence
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.recurrence <> 'None'
GROUP BY e.event_id, e.name, ei.recurrence, ei.recurrence_description
ORDER BY instance_count DESC;
```
### Events by Day of Week
```sql theme={null}
-- See which days are busiest
SELECT
TO_CHAR(starts_at, 'FMDay') as day_of_week,
EXTRACT(DOW FROM starts_at) as day_number,
COUNT(*) as event_count
FROM planning_center.calendar_event_instances
WHERE starts_at >= CURRENT_DATE - INTERVAL '90 days'
AND starts_at <= CURRENT_DATE + INTERVAL '30 days'
GROUP BY TO_CHAR(starts_at, 'FMDay'), EXTRACT(DOW FROM starts_at)
ORDER BY day_number;
```
## Resources and Rooms
### List All Resources
```sql theme={null}
-- View all bookable resources
SELECT
resource_id,
name,
kind, -- Room or Resource
description,
quantity,
home_location
FROM planning_center.calendar_resources
ORDER BY kind, name;
```
### Available Rooms
```sql theme={null}
-- Find all rooms and their details
SELECT
resource_id,
name,
description,
home_location,
quantity as capacity,
path_name -- Location hierarchy
FROM planning_center.calendar_resources
WHERE kind = 'Room'
ORDER BY name;
```
### Non-Room Resources
```sql theme={null}
-- List all non-room resources (equipment, etc.)
SELECT
resource_id,
name,
description,
quantity,
serial_number,
home_location
FROM planning_center.calendar_resources
WHERE kind = 'Resource'
ORDER BY name;
```
### Resources Expiring Soon
```sql theme={null}
-- Resources with expiration dates (like rentals or leases)
SELECT
name,
kind,
expires_at,
CASE
WHEN expires_at < CURRENT_DATE THEN 'Expired'
WHEN expires_at < CURRENT_DATE + INTERVAL '30 days' THEN 'Expiring Soon'
ELSE 'Active'
END as status
FROM planning_center.calendar_resources
WHERE expires_at IS NOT NULL
ORDER BY expires_at;
```
## Resource Bookings
### Current Bookings
```sql theme={null}
-- See what's booked right now
SELECT
r.name as resource_name,
r.kind as resource_type,
rb.starts_at,
rb.ends_at,
rb.quantity as quantity_booked
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr.relationship_id
WHERE rb.starts_at <= CURRENT_TIMESTAMP
AND rb.ends_at >= CURRENT_TIMESTAMP
ORDER BY r.name;
```
### Room Schedule for Specific Date
```sql theme={null}
-- Check room bookings for a specific date
SELECT
r.name as room_name,
rb.starts_at::time as start_time,
rb.ends_at::time as end_time,
ei.location,
e.name as event_name
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr_res
ON rbr_res.resource_booking_id = rb.resource_booking_id AND rbr_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr_res.relationship_id
JOIN planning_center.calendar_resource_bookings_relationships rbr_ei
ON rbr_ei.resource_booking_id = rb.resource_booking_id AND rbr_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei ON ei.event_instance_id = rbr_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE r.kind = 'Room'
AND DATE(rb.starts_at) = CURRENT_DATE -- Change to any date you want
ORDER BY r.name, rb.starts_at;
```
### Find Available Resources
```sql theme={null}
-- Find resources NOT booked during a specific time
SELECT
r.resource_id,
r.name,
r.kind,
r.quantity
FROM planning_center.calendar_resources r
WHERE r.kind = 'Room' -- Change to desired resource type
AND NOT EXISTS (
SELECT 1
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
WHERE rbr.relationship_id = r.resource_id
AND rb.starts_at < (CURRENT_DATE + TIME '14:00') -- Your end time
AND rb.ends_at > (CURRENT_DATE + TIME '12:00') -- Your start time
)
ORDER BY r.name;
```
### Resource Utilization Summary
```sql theme={null}
-- See how often each resource is used
SELECT
r.name as resource_name,
r.kind as resource_type,
COUNT(rb.resource_booking_id) as total_bookings,
SUM(EXTRACT(EPOCH FROM (rb.ends_at - rb.starts_at))/3600) as total_hours_booked,
MIN(rb.starts_at) as first_booking,
MAX(rb.ends_at) as last_booking
FROM planning_center.calendar_resources r
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.relationship_id = r.resource_id AND rbr.relationship_type = 'Resource'
LEFT JOIN planning_center.calendar_resource_bookings rb
ON rb.resource_booking_id = rbr.resource_booking_id
GROUP BY r.resource_id, r.name, r.kind
ORDER BY total_bookings DESC;
```
## Date and Time Queries
### Today's Events
```sql theme={null}
-- All events happening today
SELECT
e.name,
CASE
WHEN ei.all_day_event THEN 'All Day'
ELSE TO_CHAR(ei.starts_at, 'HH12:MI AM')
END as start_time,
ei.location
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE (DATE(ei.starts_at) = CURRENT_DATE
OR (ei.all_day_event = true
AND DATE(ei.starts_at) <= CURRENT_DATE
AND DATE(ei.ends_at) >= CURRENT_DATE))
ORDER BY ei.all_day_event DESC, ei.starts_at;
```
### This Month's Events
```sql theme={null}
-- All events in the current month
SELECT
e.name,
ei.starts_at,
ei.location,
ei.all_day_event
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.starts_at >= DATE_TRUNC('month', CURRENT_DATE)
AND ei.starts_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
ORDER BY ei.starts_at;
```
### Weekend Events
```sql theme={null}
-- Events on Saturdays and Sundays
SELECT
e.name,
ei.starts_at,
TO_CHAR(ei.starts_at, 'FMDay') as day_name,
ei.location
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE EXTRACT(DOW FROM ei.starts_at) IN (0, 6) -- 0 = Sunday, 6 = Saturday
AND ei.starts_at >= CURRENT_DATE
AND ei.starts_at < CURRENT_DATE + INTERVAL '30 days'
ORDER BY ei.starts_at;
```
### Events by Time of Day
```sql theme={null}
-- Categorize events by time of day
WITH categorized AS (
SELECT
CASE
WHEN EXTRACT(HOUR FROM starts_at) < 12 THEN 'Morning'
WHEN EXTRACT(HOUR FROM starts_at) < 17 THEN 'Afternoon'
ELSE 'Evening'
END as time_of_day
FROM planning_center.calendar_event_instances
WHERE starts_at >= CURRENT_DATE - INTERVAL '30 days'
AND all_day_event = false
)
SELECT
time_of_day,
COUNT(*) as event_count
FROM categorized
GROUP BY time_of_day
ORDER BY
CASE time_of_day
WHEN 'Morning' THEN 1
WHEN 'Afternoon' THEN 2
WHEN 'Evening' THEN 3
END;
```
## Tags and Categories
### Events with Tags
```sql theme={null}
-- Find events by their tags (using relationship tables)
SELECT DISTINCT
e.name as event_name,
t.name as tag_name,
tg.name as tag_group
FROM planning_center.calendar_events e
JOIN planning_center.calendar_events_relationships er
ON e.event_id = er.event_id
AND er.relationship_type = 'Tag'
JOIN planning_center.calendar_tags t
ON er.relationship_id = t.tag_id
LEFT JOIN planning_center.calendar_tag_groups_relationships tgr
ON tgr.relationship_id = t.tag_id AND tgr.relationship_type = 'Tag'
LEFT JOIN planning_center.calendar_tag_groups tg
ON tg.tag_group_id = tgr.tag_group_id
ORDER BY e.name, tg.name, t.name;
```
### Popular Tags
```sql theme={null}
-- See which tags are used most
SELECT
t.name as tag_name,
tg.name as tag_group,
COUNT(DISTINCT er.event_id) as event_count
FROM planning_center.calendar_tags t
LEFT JOIN planning_center.calendar_tag_groups_relationships tgr
ON tgr.relationship_id = t.tag_id AND tgr.relationship_type = 'Tag'
LEFT JOIN planning_center.calendar_tag_groups tg
ON tg.tag_group_id = tgr.tag_group_id
LEFT JOIN planning_center.calendar_events_relationships er
ON t.tag_id = er.relationship_id
AND er.relationship_type = 'Tag'
GROUP BY t.tag_id, t.name, tg.name
ORDER BY event_count DESC;
```
## Tips for Writing Calendar Queries
### 1. Handle All-Day Events
```sql theme={null}
-- All-day events span from start date to end date
SELECT event_instance_id, name, starts_at, ends_at
FROM planning_center.calendar_event_instances
WHERE all_day_event = true
AND DATE(starts_at) <= CURRENT_DATE
AND DATE(ends_at) >= CURRENT_DATE;
```
### 2. Work with Timestamps
```sql theme={null}
-- Extract useful parts from timestamps
SELECT
DATE(starts_at) as event_date,
starts_at::time as start_time,
TO_CHAR(starts_at, 'FMDay') as day_name,
EXTRACT(HOUR FROM starts_at) as hour_of_day
FROM planning_center.calendar_event_instances;
```
### 3. Calculate Duration
```sql theme={null}
-- Get event duration in hours
SELECT
event_instance_id,
name,
EXTRACT(EPOCH FROM (ends_at - starts_at)) / 3600 as hours
FROM planning_center.calendar_event_instances;
```
### 4. Check Resource Availability
```sql theme={null}
-- Ensure no overlapping bookings (join via relationship table)
SELECT r.resource_id, r.name
FROM planning_center.calendar_resources r
WHERE NOT EXISTS (
SELECT 1 FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
WHERE rbr.relationship_id = r.resource_id
AND rb.starts_at < (CURRENT_DATE + TIME '12:00')
AND rb.ends_at > (CURRENT_DATE + TIME '10:00')
);
```
### 5. Format Times for Display
```sql theme={null}
-- User-friendly time formats
SELECT
TO_CHAR(starts_at, 'HH12:MI AM') as time_12hr, -- 02:30 PM
TO_CHAR(starts_at, 'HH24:MI') as time_24hr, -- 14:30
TO_CHAR(starts_at, 'Mon DD, YYYY') as date_str -- Jan 15, 2024
FROM planning_center.calendar_event_instances;
```
## Common Issues & Solutions
### Issue: Events appearing multiple times
**Solution**: You might be joining to event instances - use DISTINCT or GROUP BY.
### Issue: Missing recurring events
**Solution**: Check the date range includes future instances, not just the master event.
### Issue: Resources showing as available when booked
**Solution**: Ensure your time comparison includes both start AND end times for overlaps.
### Issue: All-day events not showing
**Solution**: Remember to check both the date AND the all\_day\_event flag.
## Next Steps
Ready for more complex queries? Check out:
* [Advanced Queries](/planning-center/calendar/advanced-queries) - Conflict detection, optimization, and analytics
* [Reporting Examples](/planning-center/calendar/reporting-examples) - Complete reports you can use immediately
# Planning Center Calendar Data Model
Source: https://docs.getparable.io/planning-center/calendar/data-model
Complete reference for Planning Center Calendar tables in Parable: events, event instances, resources, rooms, tags, and the relationships between them.
This document provides complete documentation of the Planning Center Calendar data model in Parable, including all tables, fields, and relationships.
## Overview
The Calendar module contains **22 entity tables** and **20 relationship tables** supporting event management, resource booking, scheduling, and calendar synchronization.
## Visual Data Model
The diagram below shows the core entities and their relationships in the Calendar module. Use it as a visual reference while exploring the detailed table definitions below.
### Core Entity Relationships
[Open diagram in new tab →](/diagrams/planning-center/calendar-data-model-01.svg)
### Key Relationships Explained
**Event Hierarchy:**
* `EVENT` is the master event (e.g., "Sunday Service")
* `EVENT_INSTANCE` is a specific occurrence (e.g., "Sunday Service on Jan 15, 2024")
* `EVENT_TIME` breaks instances into periods (Setup 8am-9am, Event 9am-11am, Teardown 11am-12pm)
**Resource Booking:**
* `RESOURCE`s are bookable items (rooms, equipment, vehicles)
* `RESOURCE_FOLDER`s organize resources hierarchically
* `RESOURCE_BOOKING` links resources to event instances
* `ROOM_SETUP` defines different configurations for room resources
**Approval Workflow:**
* `EVENT_RESOURCE_REQUEST` initiates booking request
* `REQUIRED_APPROVAL` specifies which groups must approve
* `RESOURCE_APPROVAL_GROUP` contains approvers
* `RESOURCE_QUESTION` collects additional information during booking
**Conflict Management:**
* `CONFLICT` tracks resource double-bookings
* Links are stored via `calendar_conflicts_relationships`
* Resolved when winner is selected
**Cross-App Connections:**
* `EVENT_CONNECTION` links calendar events to other Planning Center modules
* Connected to Services (worship plans), Groups (group events), etc.
* Enables unified scheduling across the platform
**Generic Relationship Pattern:**
* All inter-entity links are stored in dedicated `*_relationships` tables
* Each relationship table holds a parent entity ID, `relationship_type`, and `relationship_id`
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Calendar module are in the `planning_center` schema. You MUST prefix all table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.calendar_events`
❌ INCORRECT: `SELECT * FROM calendar_events`
### Row Level Security (RLS)
This database uses Row Level Security (RLS) to automatically filter data based on:
* **tenant\_organization\_id**: You only see data for your current organization
* **system\_status**: You only see 'active' records by default
**DO NOT add these filters to your WHERE clause** - they are applied automatically:
* ❌ `WHERE tenant_organization_id = 1` (unnecessary)
* ❌ `WHERE system_status = 'active'` (unnecessary)
The RLS policies ensure you only access data you're authorized to see, making these filters redundant and potentially causing performance issues.
## Core Tables Overview
### Primary Entity Tables
* `calendar_events` - Master event definitions
* `calendar_event_instances` - Specific occurrences of events
* `calendar_resources` - Bookable resources (rooms, equipment)
* `calendar_resource_bookings` - Resource reservations
* `calendar_conflicts` - Scheduling conflicts
* `calendar_tags` - Event categorization
* `calendar_tag_groups` - Tag groupings
### Supporting Entity Tables
* `calendar_attachments` - File attachments for events
* `calendar_event_connections` - Links to other PCO modules
* `calendar_event_resource_requests` - Resource booking requests
* `calendar_event_resource_answers` - Answers to booking questions
* `calendar_event_times` - Time blocks within events
* `calendar_feeds` - Calendar feed configurations
* `calendar_organizations` - Organization settings
* `calendar_people` - People associated with events
* `calendar_report_templates` - Report templates
* `calendar_required_approvals` - Approval requirements
* `calendar_resource_approval_groups` - Approval groups
* `calendar_resource_folders` - Resource organization
* `calendar_resource_questions` - Booking questions
* `calendar_resource_suggestions` - Suggested resources
* `calendar_room_setups` - Room configurations
### Relationship Tables
* `calendar_attachments_relationships` - Links attachments to related entities
* `calendar_conflicts_relationships` - Links conflicts to related entities
* `calendar_event_connections_relationships` - Links connections to related entities
* `calendar_event_resource_answers_relationships` - Links answers to related entities
* `calendar_event_times_relationships` - Links event times to related entities
* `calendar_event_instances_relationships` - Links instances to related entities
* `calendar_event_resource_requests_relationships` - Links requests to related entities
* `calendar_events_relationships` - Links events to related entities
* `calendar_feeds_relationships` - Links feeds to related entities
* `calendar_people_relationships` - Links people to related entities
* `calendar_required_approvals_relationships` - Links required approvals to related entities
* `calendar_resource_approval_groups_relationships` - Links approval groups to related entities
* `calendar_resource_folders_relationships` - Links folders to related entities
* `calendar_resource_questions_relationships` - Links questions to related entities
* `calendar_resource_suggestions_relationships` - Links suggestions to related entities
* `calendar_resource_bookings_relationships` - Links bookings to related entities
* `calendar_resources_relationships` - Links resources to related entities
* `calendar_room_setups_relationships` - Links room setups to related entities
* `calendar_tag_groups_relationships` - Links tag groups to related entities
* `calendar_tags_relationships` - Links tags to related entities
## Table Definitions
### calendar\_events
Master event definitions containing the core event information.
| Column | Type | Description |
| -------------------------- | ------------- | ------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_id` | VARCHAR(64) | Planning Center event ID |
| `approval_status` | VARCHAR(1) | `'A'` (Approved), `'P'` (Pending), or `'R'` (Rejected) — never NULL |
| `created_at` | TIMESTAMP | When event was created |
| `description` | TEXT | Full event description |
| `featured` | BOOLEAN | Whether event is featured/highlighted |
| `image_url` | VARCHAR(2048) | Event image URL |
| `name` | TEXT | Event name/title |
| `percent_approved` | INTEGER | Percentage of approvals received |
| `percent_rejected` | INTEGER | Percentage of rejections received |
| `registration_url` | VARCHAR(2048) | External registration link |
| `summary` | TEXT | Brief event summary |
| `updated_at` | TIMESTAMP | Last update time |
| `visible_in_church_center` | BOOLEAN | Public visibility flag |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status: 'active', 'transferring', 'stale' |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_event\_instances
Specific occurrences of events, handling both one-time and recurring events.
| Column | Type | Description |
| -------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_instance_id` | VARCHAR(64) | Planning Center instance ID |
| `all_day_event` | BOOLEAN | Whether this is an all-day event |
| `church_center_url` | VARCHAR(2048) | Public URL for this instance |
| `compact_recurrence_description` | TEXT | Short recurrence description |
| `created_at` | TIMESTAMP | Instance creation time |
| `description` | TEXT | Instance description |
| `ends_at` | TIMESTAMP | Event end time |
| `image_url` | VARCHAR(2048) | Instance image URL |
| `location` | TEXT | Event location/venue |
| `name` | TEXT | Instance name (may differ from parent event) |
| `published_ends_at` | TIMESTAMP | Published end time (may differ from actual) |
| `published_starts_at` | TIMESTAMP | Published start time |
| `recurrence` | TEXT | `'None'`, `'Daily'`, `'Weekly'`, `'Monthly'`, `'Yearly'`, `'CustomDates'`, `'YearMonth'`, or `'MonthDay'` (never NULL) |
| `recurrence_description` | TEXT | Human-readable recurrence |
| `starts_at` | TIMESTAMP | Event start time |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier (link to parent event via `calendar_event_instances_relationships` with `relationship_type='Event'`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resources
Bookable resources including rooms, equipment, and other facilities.
| Column | Type | Description |
| ------------------------ | ------------- | ----------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_id` | VARCHAR(64) | Planning Center resource ID |
| `created_at` | TIMESTAMP | Resource creation time |
| `description` | TEXT | Resource description |
| `expires_at` | TIMESTAMP | Expiration date (for leased/rented items) |
| `home_location` | TEXT | Default storage location |
| `image_url` | VARCHAR(2048) | Resource image |
| `image_thumb_url` | VARCHAR(2048) | Thumbnail image |
| `kind` | TEXT | Resource type: 'Room' or 'Resource' |
| `name` | TEXT | Resource name |
| `path_name` | TEXT | Hierarchical path (Building/Floor/Room) |
| `quantity` | INTEGER | Available quantity/capacity |
| `serial_number` | TEXT | Serial number (for equipment) |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_bookings
Reservations linking resources to specific events and times.
| Column | Type | Description |
| ------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `resource_booking_id` | VARCHAR(64) | Planning Center booking ID |
| `created_at` | TIMESTAMP | Booking creation time |
| `ends_at` | TIMESTAMP | Booking end time |
| `quantity` | INTEGER | Quantity booked |
| `starts_at` | TIMESTAMP | Booking start time |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier (links to event, event instance, request, and resource via `calendar_resource_bookings_relationships`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_conflicts
Detected scheduling conflicts between events or resources.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `conflict_id` | VARCHAR(64) | Planning Center conflict ID |
| `created_at` | TIMESTAMP | When conflict was detected |
| `note` | TEXT | Conflict notes/resolution |
| `resolved_at` | TIMESTAMP | When conflict was resolved |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier (conflicting bookings linked via `calendar_conflicts_relationships`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_tags
Event categorization labels for organizing and filtering.
| Column | Type | Description |
| ------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `tag_id` | VARCHAR(64) | Planning Center tag ID |
| `church_center_category` | BOOLEAN | Whether this tag represents a Church Center category |
| `color` | TEXT | Display color for the tag |
| `created_at` | TIMESTAMP | Tag creation time |
| `name` | TEXT | Tag name |
| `position` | REAL | Display order |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier (link to tag group via `calendar_tags_relationships` with `relationship_type='TagGroup'`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_tag\_groups
Groupings for tags to organize them by ministry, event type, etc.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `tag_group_id` | VARCHAR(64) | Planning Center tag group ID |
| `created_at` | TIMESTAMP | Group creation time |
| `name` | TEXT | Group name |
| `required` | BOOLEAN | Whether a tag from this group is required on events |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_attachments
File attachments associated with events.
| Column | Type | Description |
| ------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `attachment_id` | VARCHAR(64) | Planning Center attachment ID |
| `content_type` | TEXT | MIME type of file |
| `created_at` | TIMESTAMP | Attachment creation time |
| `description` | TEXT | Attachment description |
| `file_size` | INTEGER | File size in bytes |
| `name` | TEXT | File name |
| `updated_at` | TIMESTAMP | Last update time |
| `url` | TEXT | Download URL |
| `tenant_organization_id` | INTEGER | Organization identifier (link to event via `calendar_attachments_relationships` with `relationship_type='Event'`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_event\_connections
Links between calendar events and other Planning Center modules.
| Column | Type | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_connection_id` | VARCHAR(64) | Planning Center connection ID |
| `connected_to_id` | VARCHAR(64) | ID of connected entity |
| `connected_to_name` | TEXT | Name of connected entity |
| `connected_to_type` | VARCHAR(50) | Type of connected entity |
| `connected_to_url` | VARCHAR(2048) | URL to connected entity |
| `product_name` | VARCHAR(50) | PCO product (services, groups, etc.) |
| `promoted` | BOOLEAN | Whether connection is promoted |
| `tenant_organization_id` | INTEGER | Organization identifier (link to event via `calendar_event_connections_relationships` with `relationship_type='Event'`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_event\_times
Specific time blocks within event instances (e.g., "Doors Open", "Main Service").
| Column | Type | Description |
| ---------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_time_id` | VARCHAR(64) | Planning Center event time ID |
| `ends_at` | TIMESTAMP | Time block end |
| `name` | TEXT | Time block name |
| `starts_at` | TIMESTAMP | Time block start |
| `visible_on_kiosks` | BOOLEAN | Show on check-in kiosks |
| `visible_on_widget_and_ical` | BOOLEAN | Show in public widgets/calendars |
| `tenant_organization_id` | INTEGER | Organization identifier (link to event instance via `calendar_event_times_relationships` with `relationship_type='EventInstance'`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_room\_setups
Predefined room configurations with layout diagrams.
| Column | Type | Description |
| ------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `room_setup_id` | VARCHAR(64) | Planning Center setup ID |
| `created_at` | TIMESTAMP | Setup creation time |
| `description` | TEXT | Setup description |
| `diagram_thumbnail_url` | VARCHAR(2048) | Diagram thumbnail |
| `diagram_url` | VARCHAR(2048) | Setup diagram image |
| `name` | TEXT | Setup name |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier (links to containing resource and associated room setup via `calendar_room_setups_relationships`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_event\_resource\_requests
Resource requests for events, tracking booking requirements.
| Column | Type | Description |
| --------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_resource_request_id` | VARCHAR(64) | Planning Center request ID |
| `approval_sent` | BOOLEAN | Whether approval request was sent |
| `approval_status` | VARCHAR(1) | Status: 'A' (Approved), 'P' (Pending), 'R' (Rejected) |
| `created_at` | TIMESTAMP | Request creation time |
| `notes` | TEXT | Request notes |
| `quantity` | INTEGER | Quantity requested |
| `updated_at` | TIMESTAMP | Last update time |
| `visible_on_kiosks` | BOOLEAN | Whether request is visible on kiosks |
| `tenant_organization_id` | INTEGER | Organization identifier (links to event, resource, room setup, created\_by, updated\_by via `calendar_event_resource_requests_relationships`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_event\_resource\_answers
Answers to resource booking questions.
| Column | Type | Description |
| -------------------------- | ----------- | ----------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_resource_answer_id` | VARCHAR(64) | Planning Center answer ID |
| `answer` | JSONB | The answer provided (string or array of strings depending on multiple\_select) |
| `question` | JSONB | Embedded question details (question, choices, kind, multiple\_select, optional, position) |
| `db_answer` | TEXT | Plain-text representation of answer |
| `created_at` | TIMESTAMP | Answer creation time |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Note:** Relationships (created\_by, updated\_by, resource\_question, event\_resource\_request) are stored in the `calendar_event_resource_answers_relationships` table.
### calendar\_feeds
Calendar feed configurations for importing and syncing events.
| Column | Type | Description |
| ---------------------------------- | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `feed_id` | VARCHAR(64) | Planning Center feed ID |
| `can_delete` | BOOLEAN | Whether feed can be deleted |
| `default_church_center_visibility` | VARCHAR(50) | Default visibility for imported events |
| `deleting` | BOOLEAN | Whether feed is being deleted |
| `feed_type` | VARCHAR(50) | Type of feed (ical, planning\_center) |
| `imported_at` | TIMESTAMP | Last import time |
| `name` | TEXT | Feed name |
| `prefix_event_names` | BOOLEAN | Whether to prefix imported event names |
| `source_id` | VARCHAR(64) | Source identifier |
| `sync_campus_tags` | BOOLEAN | Whether to sync campus tags |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_organizations
Organization-wide calendar settings.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `organization_id` | VARCHAR(64) | Planning Center organization ID |
| `calendar_starts_on` | VARCHAR(50) | Which day the calendar week starts on |
| `date_format` | VARCHAR(255) | Date display format |
| `name` | TEXT | Organization name |
| `onboarding` | BOOLEAN | Whether organization is in onboarding |
| `time_zone` | VARCHAR(255) | Default timezone |
| `twenty_four_hour_time` | BOOLEAN | Use 24-hour time format |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_people
People with calendar permissions and access.
| Column | Type | Description |
| ---------------------------- | ------------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_id` | VARCHAR(64) | Planning Center person ID |
| `avatar_url` | VARCHAR(2048) | Profile image URL |
| `can_edit_people` | BOOLEAN | Can edit people permissions |
| `can_edit_resources` | BOOLEAN | Can edit resources |
| `can_edit_rooms` | BOOLEAN | Can edit rooms |
| `child` | BOOLEAN | Is a child account |
| `contact_data` | JSONB | Contact information |
| `created_at` | TIMESTAMP | Person creation time |
| `event_permissions_type` | VARCHAR(50) | Event permission level |
| `first_name` | VARCHAR(255) | First name |
| `gender` | VARCHAR(10) | Gender |
| `has_access` | BOOLEAN | Has calendar access |
| `last_name` | VARCHAR(255) | Last name |
| `member_of_approval_groups` | BOOLEAN | Whether person is in any approval group |
| `middle_name` | VARCHAR(255) | Middle name |
| `name` | VARCHAR(255) | Full name |
| `name_prefix` | VARCHAR(10) | Name prefix (Mr., Dr., etc.) |
| `name_suffix` | VARCHAR(10) | Name suffix (Jr., III, etc.) |
| `pending_request_count` | INTEGER | Number of pending approval requests |
| `people_permissions_type` | TEXT | People permission level |
| `permissions` | INTEGER | Permission bitmask |
| `resolves_conflicts` | BOOLEAN | Can resolve booking conflicts |
| `resources_permissions_type` | TEXT | Resource permission level |
| `room_permissions_type` | TEXT | Room permission level |
| `site_administrator` | BOOLEAN | Is site administrator |
| `status` | TEXT | Account status |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_report\_templates
Report templates for calendar data.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `report_template_id` | VARCHAR(64) | Planning Center template ID |
| `body` | TEXT | Template body/content |
| `created_at` | TIMESTAMP | Template creation time |
| `description` | TEXT | Template description |
| `title` | TEXT | Template title |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_required\_approvals
Approval requirements for resource bookings.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `required_approval_id` | VARCHAR(64) | Planning Center approval ID |
| `tenant_organization_id` | INTEGER | Organization identifier (links to approval group and resource via `calendar_required_approvals_relationships`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_approval\_groups
Groups responsible for approving resource bookings.
| Column | Type | Description |
| ---------------------------- | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_approval_group_id` | VARCHAR(64) | Planning Center group ID |
| `created_at` | TIMESTAMP | Group creation time |
| `form_count` | INTEGER | Number of forms associated |
| `name` | TEXT | Group name |
| `resource_count` | INTEGER | Number of resources this group approves |
| `room_count` | INTEGER | Number of rooms this group approves |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_folders
Hierarchical organization of resources.
| Column | Type | Description |
| ------------------------ | ----------- | ------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_folder_id` | VARCHAR(64) | Planning Center folder ID |
| `ancestry` | TEXT | Hierarchical path (parent/child/grandchild) |
| `created_at` | TIMESTAMP | Folder creation time |
| `kind` | TEXT | Folder type |
| `name` | TEXT | Folder name |
| `path_name` | TEXT | Full path name |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_questions
Booking questions for specific resources.
| Column | Type | Description |
| ------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `resource_question_id` | VARCHAR(64) | Planning Center question ID |
| `choices` | TEXT | Available choices (for select fields) |
| `created_at` | TIMESTAMP | Question creation time |
| `description` | TEXT | Question description/help text |
| `kind` | TEXT | Question type (text, select, etc.) |
| `multiple_select` | BOOLEAN | Allow multiple selections |
| `optional` | BOOLEAN | Is answer optional |
| `position` | INTEGER | Display order |
| `question` | TEXT | Question text |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier (link to resource via `calendar_resource_questions_relationships` with `relationship_type='Resource'`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_suggestions
Suggested resources for room setups.
| Column | Type | Description |
| ------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `resource_suggestion_id` | VARCHAR(64) | Planning Center suggestion ID |
| `created_at` | TIMESTAMP | Suggestion creation time |
| `quantity` | INTEGER | Suggested quantity |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier (links to resource and room setup via `calendar_resource_suggestions_relationships`) |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
## Relationship Tables
All relationship tables share this structure: a parent entity ID, `relationship_type` (VARCHAR(50)), and `relationship_id` (VARCHAR(64)) to identify the related record, plus standard system fields.
### calendar\_attachments\_relationships
Links attachments to related entities (events).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `attachment_id` | VARCHAR(64) | Parent attachment ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_conflicts\_relationships
Links conflicts to related entities (resource bookings).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `conflict_id` | VARCHAR(64) | Parent conflict ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_event\_connections\_relationships
Links event connections to related entities (events).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_connection_id` | VARCHAR(64) | Parent connection ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_event\_resource\_answers\_relationships
Links event resource answers to related entities (created\_by, updated\_by, resource\_question, event\_resource\_request).
| Column | Type | Description |
| -------------------------- | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_resource_answer_id` | VARCHAR(64) | Parent answer ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
Common relationship types:
* `Person` (created\_by) - Links to calendar\_people (who created the answer)
* `Person` (updated\_by) - Links to calendar\_people (who last updated the answer)
* `ResourceQuestion` - Links to calendar\_resource\_questions
* `EventResourceRequest` - Links to calendar\_event\_resource\_requests
### calendar\_event\_times\_relationships
Links event times to related entities (event instances).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_time_id` | VARCHAR(64) | Parent event time ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_event\_instances\_relationships
Links event instances to event times, resource bookings, and tags.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_instance_id` | VARCHAR(64) | Parent instance ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
Common relationship types:
* `Event` - Links to calendar\_events
* `EventTime` - Links to calendar\_event\_times
* `ResourceBooking` - Links to calendar\_resource\_bookings
* `Tag` - Links to calendar\_tags
### calendar\_event\_resource\_requests\_relationships
Links resource requests to answers and bookings.
| Column | Type | Description |
| --------------------------- | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_resource_request_id` | VARCHAR(64) | Parent request ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
Common relationship types:
* `ResourceBooking` - Links to calendar\_resource\_bookings
* `EventResourceAnswer` - Links to calendar\_event\_resource\_answers
### calendar\_events\_relationships
Links events to tags, attachments, and other related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_id` | VARCHAR(64) | Parent event ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
Common relationship types:
* `Tag` - Links to calendar\_tags
* `Attachment` - Links to calendar\_attachments
* `Owner` - Links to calendar\_people
### calendar\_feeds\_relationships
Links feeds to related entities (event owners).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `feed_id` | VARCHAR(64) | Parent feed ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_people\_relationships
Links people to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_id` | VARCHAR(64) | Parent person ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_required\_approvals\_relationships
Links required approvals to related entities (approval groups, resources).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `required_approval_id` | VARCHAR(64) | Parent required approval ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_approval\_groups\_relationships
Links approval groups to related entities (people, resources).
| Column | Type | Description |
| ---------------------------- | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_approval_group_id` | VARCHAR(64) | Parent approval group ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_folders\_relationships
Links resource folders to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_folder_id` | VARCHAR(64) | Parent folder ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_questions\_relationships
Links resource questions to related entities (resources).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_question_id` | VARCHAR(64) | Parent question ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_suggestions\_relationships
Links resource suggestions to related entities (resources, room setups).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_suggestion_id` | VARCHAR(64) | Parent suggestion ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_resource\_bookings\_relationships
Links resource bookings to related entities (events, instances, requests, resources).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_booking_id` | VARCHAR(64) | Parent booking ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
Common relationship types:
* `Event` - Links to calendar\_events
* `EventInstance` - Links to calendar\_event\_instances
* `EventResourceRequest` - Links to calendar\_event\_resource\_requests
* `Resource` - Links to calendar\_resources
### calendar\_resources\_relationships
Links resources to approval groups, questions, and room setups.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_id` | VARCHAR(64) | Parent resource ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
Common relationship types:
* `ResourceApprovalGroup` - Links to calendar\_resource\_approval\_groups
* `ResourceQuestion` - Links to calendar\_resource\_questions
* `RoomSetup` - Links to calendar\_room\_setups
### calendar\_room\_setups\_relationships
Links room setups to related entities (containing resource, associated room setup).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `room_setup_id` | VARCHAR(64) | Parent room setup ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_tag\_groups\_relationships
Links tag groups to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `tag_group_id` | VARCHAR(64) | Parent tag group ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### calendar\_tags\_relationships
Links tags to related entities (tag groups).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `tag_id` | VARCHAR(64) | Parent tag ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
## System Fields
All tables include these system fields for data management:
* `tenant_organization_id` - Multi-tenant organization identifier
* `system_status` - Data lifecycle status:
* `transferring` - Being imported from Planning Center
* `active` - Current active data
* `stale` - Marked for removal
* `system_created_at` - When record was created in Parable
* `system_updated_at` - When record was last updated in Parable
## Common Query Patterns
### Getting Events with Tags
```sql theme={null}
-- CORRECT: Schema prefix included, no manual RLS filters
SELECT
e.*,
STRING_AGG(t.name, ', ') as tags
FROM planning_center.calendar_events e
LEFT JOIN planning_center.calendar_events_relationships er
ON e.event_id = er.event_id
AND er.relationship_type = 'Tag'
LEFT JOIN planning_center.calendar_tags t
ON er.relationship_id = t.tag_id
GROUP BY e.id, e.event_id;
```
### Finding Available Resources
```sql theme={null}
SELECT r.*
FROM planning_center.calendar_resources r
WHERE NOT EXISTS (
SELECT 1
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
WHERE rbr.relationship_id = r.resource_id
AND rb.starts_at < (CURRENT_DATE + TIME '14:00')
AND rb.ends_at > (CURRENT_DATE + TIME '12:00')
);
```
### Detecting Booking Conflicts
```sql theme={null}
SELECT
rb1.resource_booking_id as booking1,
rb2.resource_booking_id as booking2,
r.name as resource_name
FROM planning_center.calendar_resource_bookings rb1
JOIN planning_center.calendar_resource_bookings_relationships rbr1
ON rbr1.resource_booking_id = rb1.resource_booking_id AND rbr1.relationship_type = 'Resource'
JOIN planning_center.calendar_resource_bookings rb2
ON rb1.resource_booking_id < rb2.resource_booking_id
AND rb1.starts_at < rb2.ends_at
AND rb1.ends_at > rb2.starts_at
JOIN planning_center.calendar_resource_bookings_relationships rbr2
ON rbr2.resource_booking_id = rb2.resource_booking_id AND rbr2.relationship_type = 'Resource'
AND rbr2.relationship_id = rbr1.relationship_id -- Same resource
JOIN planning_center.calendar_resources r ON r.resource_id = rbr1.relationship_id;
```
### Today's Schedule
```sql theme={null}
SELECT
e.name as event_name,
ei.starts_at,
ei.ends_at,
ei.location
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE (
DATE(ei.starts_at) = CURRENT_DATE
OR (ei.all_day_event = true
AND DATE(ei.starts_at) <= CURRENT_DATE
AND DATE(ei.ends_at) >= CURRENT_DATE)
)
ORDER BY ei.starts_at;
```
## Data Integrity Rules
1. **Schema Qualification**: Always use `planning_center.` prefix for all table references
2. **Row Level Security**: RLS automatically handles multi-tenancy and status filtering - do not add manual filters
3. **Monetary Values**: Resource fee and cost columns are stored in cents - divide by 100.0 for display
4. **Time Windows**: Use `starts_at`/`ends_at` comparisons and the `all_day_event` flag rather than relying on `system_status`
5. **Relationship Tables**: All inter-entity links are stored in `*_relationships` tables — never in direct columns on the entity tables
## Common Mistakes to Avoid
1. **Missing Schema Prefix**
* ❌ `FROM calendar_events`
* ✅ `FROM planning_center.calendar_events`
2. **Adding Redundant RLS Filters**
* ❌ `WHERE tenant_organization_id = 1 AND system_status = 'active'`
* ✅ Trust RLS to handle this automatically
3. **Joining Without Schema**
* ❌ `JOIN calendar_resource_bookings rb ON ...`
* ✅ `JOIN planning_center.calendar_resource_bookings rb ON ...`
4. **Using the wrong parent column in relationship tables**
* ❌ `WHERE event_resource_request_id = ...` in `calendar_event_resource_requests_relationships`
* ✅ `WHERE event_resource_request_id = ...` (the live column is `event_resource_request_id`)
5. **Using the wrong parent column in eventinstances relationship table**
* ❌ `WHERE event_instance_id = ...` in `calendar_event_instances_relationships`
* ✅ `WHERE event_instance_id = ...` (the live column is `event_instance_id`)
## Performance Considerations
1. **Indexes**: All tables have optimized indexes on:
* Primary keys and entity IDs
* Join columns and foreign keys
* Date columns for time-based queries
2. **Query Optimization**:
* Always use the `planning_center.` schema prefix
* RLS handles tenant and status filtering automatically
* Filter by approval or conflict flags when relevant
* Consider CTEs for complex hierarchical queries
* Use relationship tables for all cross-entity joins
## Data Types and Conventions
### Approval Statuses
* `A` - Approved
* `P` - Pending approval
* `R` - Rejected
* `NULL` - No approval required
### Resource Types (kind)
* `Room` - Physical spaces
* `Resource` - Other bookable items (equipment, etc.)
### All-Day Events
* `all_day_event = true` indicates full-day events
* Check date portions of `starts_at` and `ends_at`
* May span multiple days
### Recurrence Patterns
* `recurrence` holds a single keyword, **not** an iCal RRULE: `'None'`,
`'Daily'`, `'Weekly'`, `'Monthly'`, `'Yearly'`, `'CustomDates'`, `'YearMonth'`,
or `'MonthDay'`. It is never NULL — filter with `recurrence <> 'None'` to
select recurring events
* Human-readable detail (days, times, start and end dates) is in
`recurrence_description`, also never NULL
* Each occurrence is a separate event\_instance
## Next Steps
* Start with [Basic Queries](/planning-center/calendar/basic-queries) for simple examples
* Progress to [Advanced Queries](/planning-center/calendar/advanced-queries) for complex analysis
* Use [Reporting Examples](/planning-center/calendar/reporting-examples) for production reports
* Return to [Overview](/planning-center/calendar/overview) for overview
# Planning Center Calendar SQL Queries
Source: https://docs.getparable.io/planning-center/calendar/overview
Query Planning Center Calendar data with SQL. Track facility utilization, check room availability, and find double-booked resources before they clash.
## Coordinate Your Ministry with Data-Driven Scheduling
Your church calendar is the heartbeat of ministry activity. With Parable's SQL access to Planning Center Calendar data, you can optimize facility usage, prevent conflicts, and ensure every event runs smoothly.
## Quick Start
Ready to explore your calendar data? Here's your first query to see upcoming events:
```sql theme={null}
-- See your next 10 upcoming events
SELECT
e.event_id,
e.name,
e.description,
ei.starts_at,
ei.ends_at,
ei.location,
ei.all_day_event
FROM planning_center.calendar_events e
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.relationship_type = 'Event' AND eir.relationship_id = e.event_id
JOIN planning_center.calendar_event_instances ei
ON ei.event_instance_id = eir.event_instance_id
WHERE ei.starts_at >= CURRENT_TIMESTAMP
ORDER BY ei.starts_at
LIMIT 10;
```
## What You Can Do With Calendar Queries
### 📅 Event Management
* View all upcoming events and their details
* Track event approval status and workflows
* Monitor recurring events and patterns
* Identify scheduling gaps and opportunities
### 🏢 Resource Management
* Check room and equipment availability
* Track resource bookings and conflicts
* Analyze facility utilization rates
* Plan maintenance around usage patterns
### ⚠️ Conflict Detection
* Find double-booked resources
* Identify scheduling conflicts
* Prevent resource overbooking
* Manage approval workflows
### 📊 Usage Analytics
* Generate facility usage reports
* Track event attendance patterns
* Analyze resource utilization
* Optimize scheduling based on data
## Available Tables
Your Planning Center Calendar data is organized into these main tables:
| Table | What It Contains | Key Use Cases |
| ---------------------------------- | -------------------------------- | ------------------------------------------------ |
| `calendar_events` | Event definitions and details | Event information, approval status, descriptions |
| `calendar_event_instances` | Specific occurrences of events | Event times, locations, recurrence patterns |
| `calendar_resources` | Rooms, equipment, and facilities | Resource inventory, availability, specifications |
| `calendar_resource_bookings` | Resource reservations | Booking times, quantities, conflicts |
| `calendar_conflicts` | Scheduling conflicts | Double bookings, resource conflicts |
| `calendar_event_resource_requests` | Resource requests for events | Pending requests, approval workflows |
| `calendar_tags` | Event categorization tags | Event types, ministries, categories |
## Understanding Relationships
Calendar data uses relationship tables to maintain flexibility:
* `calendar_events_relationships` - Links events to owners, tags, and other entities
* `calendar_event_instances_relationships` - Links instances to resources and bookings
* `calendar_resources_relationships` - Links resources to folders and approval groups
* `calendar_resource_bookings_relationships` - Links bookings to events and resources
## Next Steps
📚 **New to SQL?** Start with [Basic Queries](/planning-center/calendar/basic-queries) for simple, powerful queries you can use today.
🚀 **Ready for More?** Check out [Advanced Queries](/planning-center/calendar/advanced-queries) for conflict detection and resource optimization.
📊 **Need Reports?** See [Reporting Examples](/planning-center/calendar/reporting-examples) for complete facility and event reports.
🔍 **Want Details?** Review the [Data Model](/planning-center/calendar/data-model) for complete table documentation.
## Common Questions
### What's the difference between an event and an event instance?
* An **event** is the master definition (like "Sunday Service")
* An **event instance** is a specific occurrence (like "Sunday Service on Jan 7, 2024")
* One event can have many instances (especially for recurring events)
### How do I find available resources?
Check for resources without bookings during your timeframe:
```sql theme={null}
-- Find available rooms for a specific time
SELECT r.name, r.kind, r.quantity
FROM planning_center.calendar_resources r
WHERE r.kind = 'Room'
AND NOT EXISTS (
SELECT 1 FROM planning_center.calendar_resource_bookings_relationships rbr
JOIN planning_center.calendar_resource_bookings rb
ON rb.resource_booking_id = rbr.resource_booking_id
WHERE rbr.relationship_id = r.resource_id
AND rbr.relationship_type = 'Resource'
AND rb.starts_at < (CURRENT_DATE + TIME '14:00') -- Your end time
AND rb.ends_at > (CURRENT_DATE + TIME '12:00') -- Your start time
);
```
### How are recurring events stored?
* The `calendar_events` table has the master event
* `calendar_event_instances` has each occurrence
* The `recurrence` field holds a single keyword — `'None'`, `'Daily'`,
`'Weekly'`, `'Monthly'`, `'Yearly'`, `'CustomDates'`, `'YearMonth'`, or
`'MonthDay'`. It is never NULL, so filter recurring events with
`recurrence <> 'None'`
* `recurrence_description` provides human-readable recurrence info
### What do approval statuses mean?
* `A` = Approved
* `P` = Pending
* `R` = Rejected
`approval_status` is never NULL — every event carries one of these three values.
### How do I handle all-day events?
All-day events have `all_day_event = true` and use date boundaries:
```sql theme={null}
SELECT event_instance_id, name, starts_at, ends_at
FROM planning_center.calendar_event_instances
WHERE all_day_event = true
AND DATE(starts_at) = CURRENT_DATE;
```
## Common Calendar Patterns
### Weekly Room Schedule
```sql theme={null}
-- See what's in each room this week
SELECT
r.name as room_name,
e.name as event_name,
ei.starts_at,
ei.ends_at
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr_res
ON rbr_res.resource_booking_id = rb.resource_booking_id AND rbr_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr_res.relationship_id
JOIN planning_center.calendar_resource_bookings_relationships rbr_ei
ON rbr_ei.resource_booking_id = rb.resource_booking_id AND rbr_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei ON ei.event_instance_id = rbr_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE r.kind = 'Room'
AND ei.starts_at >= DATE_TRUNC('week', CURRENT_DATE)
AND ei.starts_at < DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '1 week'
ORDER BY r.name, ei.starts_at;
```
### Today's Events
```sql theme={null}
-- All events happening today
SELECT
e.name,
ei.starts_at::time as start_time,
ei.ends_at::time as end_time,
ei.location
FROM planning_center.calendar_events e
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.relationship_type = 'Event' AND eir.relationship_id = e.event_id
JOIN planning_center.calendar_event_instances ei ON ei.event_instance_id = eir.event_instance_id
WHERE DATE(ei.starts_at) = CURRENT_DATE
OR (ei.all_day_event = true AND DATE(ei.starts_at) <= CURRENT_DATE AND DATE(ei.ends_at) >= CURRENT_DATE)
ORDER BY ei.starts_at;
```
## Getting Help
* 🐛 Found an issue? Report it at [github.com/getparable/parable-api/issues](https://github.com/getparable/parable-api/issues)
* 📖 Need more examples? Check our other query guides in this folder
* 💬 Have questions? Reach out to your Parable support team
***
*Your ministry calendar tells a story of community and service. Let's help you manage it better.*
# Planning Center Calendar Report Examples
Source: https://docs.getparable.io/planning-center/calendar/reporting-examples
Production-ready SQL reports for church facility management: room usage summaries, event schedules, and resource conflicts for ministry planning.
Production-ready SQL reports for facility management, event coordination, and ministry planning.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Calendar module live in the `planning_center` schema. Always prefix table names with `planning_center.` in every query.
✅ CORRECT: `SELECT * FROM planning_center.calendar_events`
❌ INCORRECT: `SELECT * FROM calendar_events`
### Row Level Security (RLS)
Row Level Security automatically handles:
* **tenant\_organization\_id** – restricts results to your organization
* **system\_status** – returns active records by default
**Do not add these filters manually**—RLS already enforces them and redundant predicates can hide data or slow performance:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Keep your focus on scheduling windows, resource capacity, and approval states while trusting RLS for tenancy and status.
## Table of Contents
* [Executive Dashboard Reports](#executive-dashboard-reports)
* [Facility Management Reports](#facility-management-reports)
* [Event Coordination Reports](#event-coordination-reports)
* [Resource Utilization Reports](#resource-utilization-reports)
* [Conflict and Compliance Reports](#conflict-and-compliance-reports)
* [Ministry Planning Reports](#ministry-planning-reports)
## Executive Dashboard Reports
### Weekly Ministry Overview
A comprehensive snapshot for leadership meetings:
```sql theme={null}
-- Executive Weekly Calendar Summary
WITH this_week AS (
SELECT
DATE_TRUNC('week', CURRENT_DATE) as week_start,
DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '6 days' as week_end
),
event_summary AS (
SELECT
COUNT(DISTINCT e.event_id) as total_events,
COUNT(DISTINCT ei.event_instance_id) as total_instances,
COUNT(DISTINCT CASE WHEN e.approval_status = 'A' THEN e.event_id END) as approved_events,
COUNT(DISTINCT CASE WHEN e.approval_status = 'P' THEN e.event_id END) as pending_events,
COUNT(DISTINCT CASE WHEN ei.all_day_event = true THEN ei.event_instance_id END) as all_day_events,
COUNT(DISTINCT DATE(ei.starts_at)) as days_with_events
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
CROSS JOIN this_week tw
WHERE ei.starts_at >= tw.week_start
AND ei.starts_at <= tw.week_end
),
resource_summary AS (
SELECT
COUNT(DISTINCT rbr.relationship_id) as resources_booked,
COUNT(DISTINCT rb.resource_booking_id) as total_bookings,
SUM(EXTRACT(EPOCH FROM (rb.ends_at - rb.starts_at))/3600) as total_hours_booked
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
CROSS JOIN this_week tw
WHERE rb.starts_at >= tw.week_start
AND rb.starts_at <= tw.week_end
),
top_event_rows AS (
SELECT
e.name || ' (' || TO_CHAR(ei.starts_at, 'Dy HH12:MI AM') || ')' as label,
ei.starts_at
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
CROSS JOIN this_week tw
WHERE ei.starts_at >= tw.week_start
AND ei.starts_at <= tw.week_end
AND e.featured = true
ORDER BY ei.starts_at
LIMIT 5
),
top_events AS (
SELECT STRING_AGG(label, ', ' ORDER BY starts_at) as featured_events
FROM top_event_rows
)
SELECT
TO_CHAR(tw.week_start, 'Mon DD') || ' - ' || TO_CHAR(tw.week_end, 'Mon DD, YYYY') as week_range,
es.total_events,
es.total_instances,
es.approved_events,
es.pending_events,
es.days_with_events,
rs.resources_booked,
rs.total_bookings as resource_bookings,
ROUND(rs.total_hours_booked::numeric, 1) as facility_hours_used,
te.featured_events
FROM this_week tw
CROSS JOIN event_summary es
CROSS JOIN resource_summary rs
CROSS JOIN top_events te;
```
### Monthly Activity Metrics
```sql theme={null}
-- Monthly Calendar Activity Dashboard
WITH monthly_metrics AS (
SELECT
DATE_TRUNC('month', ei.starts_at) as month,
COUNT(DISTINCT e.event_id) as unique_events,
COUNT(DISTINCT ei.event_instance_id) as total_occurrences,
COUNT(DISTINCT DATE(ei.starts_at)) as active_days,
COUNT(DISTINCT ei.location) as unique_locations,
COUNT(DISTINCT CASE WHEN e.visible_in_church_center THEN e.event_id END) as public_events,
SUM(CASE WHEN ei.all_day_event THEN 1 ELSE 0 END) as all_day_count
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.starts_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '12 months')
GROUP BY DATE_TRUNC('month', ei.starts_at)
),
with_comparisons AS (
SELECT
month,
TO_CHAR(month, 'FMMonth YYYY') as month_year,
unique_events,
total_occurrences,
active_days,
unique_locations,
public_events,
LAG(total_occurrences, 1) OVER (ORDER BY month) as prev_month_occurrences,
LAG(total_occurrences, 12) OVER (ORDER BY month) as year_ago_occurrences
FROM monthly_metrics
)
SELECT
month_year,
unique_events,
total_occurrences,
active_days,
ROUND(total_occurrences::numeric / NULLIF(active_days, 0), 1) as avg_events_per_active_day,
unique_locations,
public_events,
ROUND(((total_occurrences - prev_month_occurrences) * 100.0 / NULLIF(prev_month_occurrences, 0)), 1) as month_over_month_pct,
ROUND(((total_occurrences - year_ago_occurrences) * 100.0 / NULLIF(year_ago_occurrences, 0)), 1) as year_over_year_pct
FROM with_comparisons
WHERE month >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '12 months')
ORDER BY month DESC;
```
## Facility Management Reports
### Room Utilization Report
```sql theme={null}
-- Comprehensive Room Utilization Analysis
WITH room_bookings AS (
SELECT
r.resource_id,
r.name as room_name,
r.home_location,
r.quantity as capacity,
DATE_TRUNC('week', rb.starts_at) as week,
COUNT(DISTINCT rb.resource_booking_id) as bookings,
SUM(EXTRACT(EPOCH FROM (rb.ends_at - rb.starts_at))/3600) as hours_used,
COUNT(DISTINCT DATE(rb.starts_at)) as days_used,
COUNT(DISTINCT eir.relationship_id) as unique_events
FROM planning_center.calendar_resources r
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.relationship_id = r.resource_id AND rbr.relationship_type = 'Resource'
LEFT JOIN planning_center.calendar_resource_bookings rb
ON rb.resource_booking_id = rbr.resource_booking_id
AND rb.starts_at >= CURRENT_DATE - INTERVAL '90 days'
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr_ei
ON rbr_ei.resource_booking_id = rb.resource_booking_id AND rbr_ei.relationship_type = 'EventInstance'
LEFT JOIN planning_center.calendar_event_instances ei
ON ei.event_instance_id = rbr_ei.relationship_id
LEFT JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
WHERE r.kind = 'Room'
GROUP BY r.resource_id, r.name, r.home_location, r.quantity,
DATE_TRUNC('week', rb.starts_at)
),
room_summary AS (
SELECT
room_name,
home_location,
capacity,
COUNT(DISTINCT week) as weeks_with_bookings,
SUM(bookings) as total_bookings,
SUM(hours_used) as total_hours,
AVG(hours_used) as avg_weekly_hours,
SUM(days_used) as total_days_used,
SUM(unique_events) as unique_events_hosted
FROM room_bookings
GROUP BY resource_id, room_name, home_location, capacity
)
SELECT
room_name,
home_location,
capacity,
total_bookings,
ROUND(total_hours, 1) as total_hours_used,
ROUND(avg_weekly_hours, 1) as avg_hours_per_week,
total_days_used,
unique_events_hosted,
ROUND(total_hours / (90.0 * 12) * 100, 1) as utilization_rate_pct, -- Assuming 12 hours/day availability
CASE
WHEN total_hours / (90.0 * 12) > 0.7 THEN 'High'
WHEN total_hours / (90.0 * 12) > 0.3 THEN 'Medium'
ELSE 'Low'
END as utilization_level
FROM room_summary
ORDER BY total_hours DESC;
```
### Maintenance Schedule Windows
```sql theme={null}
-- Find optimal maintenance windows based on usage patterns
WITH hourly_usage AS (
SELECT
r.resource_id,
r.name as resource_name,
EXTRACT(DOW FROM rb.starts_at) as day_of_week,
EXTRACT(HOUR FROM rb.starts_at) as hour_of_day,
COUNT(*) as booking_count
FROM planning_center.calendar_resources r
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.relationship_id = r.resource_id AND rbr.relationship_type = 'Resource'
LEFT JOIN planning_center.calendar_resource_bookings rb
ON rb.resource_booking_id = rbr.resource_booking_id
AND rb.starts_at >= CURRENT_DATE - INTERVAL '180 days'
WHERE r.kind = 'Room'
GROUP BY r.resource_id, r.name,
EXTRACT(DOW FROM rb.starts_at),
EXTRACT(HOUR FROM rb.starts_at)
),
usage_patterns AS (
SELECT
resource_name,
day_of_week,
hour_of_day,
booking_count,
SUM(booking_count) OVER (PARTITION BY resource_name) as total_bookings,
RANK() OVER (PARTITION BY resource_name ORDER BY booking_count) as usage_rank
FROM hourly_usage
)
SELECT
resource_name,
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 best_maintenance_day,
hour_of_day || ':00-' || (hour_of_day + 1) || ':00' as best_maintenance_time,
booking_count as typical_bookings_at_this_time,
ROUND(booking_count * 100.0 / NULLIF(total_bookings, 0), 2) as pct_of_total_usage
FROM usage_patterns
WHERE usage_rank <= 3 -- Bottom 3 usage slots
ORDER BY resource_name, booking_count;
```
## Event Coordination Reports
### Event Setup Requirements
```sql theme={null}
-- Comprehensive Event Setup Checklist
WITH upcoming_events AS (
SELECT
e.event_id,
e.name as event_name,
e.description,
ei.event_instance_id,
ei.starts_at,
ei.ends_at,
ei.location,
ei.all_day_event,
e.approval_status
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.starts_at >= CURRENT_DATE
AND ei.starts_at < CURRENT_DATE + INTERVAL '14 days'
),
event_resources AS (
SELECT
ue.event_instance_id,
STRING_AGG(
r.name || ' (Qty: ' || rb.quantity || ')',
', ' ORDER BY r.name
) as resources_needed,
COUNT(DISTINCT rbr_res.relationship_id) as resource_count,
SUM(rb.quantity) as total_items
FROM upcoming_events ue
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr_ei
ON rbr_ei.relationship_id = ue.event_instance_id AND rbr_ei.relationship_type = 'EventInstance'
LEFT JOIN planning_center.calendar_resource_bookings rb
ON rb.resource_booking_id = rbr_ei.resource_booking_id
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr_res
ON rbr_res.resource_booking_id = rb.resource_booking_id AND rbr_res.relationship_type = 'Resource'
LEFT JOIN planning_center.calendar_resources r
ON r.resource_id = rbr_res.relationship_id
GROUP BY ue.event_instance_id
),
event_conflicts AS (
SELECT
rbr1_ei.relationship_id as event_instance_id,
COUNT(DISTINCT rbr2_ei.relationship_id) as conflicting_events
FROM planning_center.calendar_resource_bookings rb1
JOIN planning_center.calendar_resource_bookings_relationships rbr1_res
ON rbr1_res.resource_booking_id = rb1.resource_booking_id AND rbr1_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resource_bookings_relationships rbr1_ei
ON rbr1_ei.resource_booking_id = rb1.resource_booking_id AND rbr1_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_resource_bookings_relationships rbr2_res
ON rbr2_res.relationship_id = rbr1_res.relationship_id AND rbr2_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resource_bookings rb2
ON rb2.resource_booking_id = rbr2_res.resource_booking_id
AND rb1.resource_booking_id != rb2.resource_booking_id
AND rb1.starts_at < rb2.ends_at
AND rb1.ends_at > rb2.starts_at
JOIN planning_center.calendar_resource_bookings_relationships rbr2_ei
ON rbr2_ei.resource_booking_id = rb2.resource_booking_id AND rbr2_ei.relationship_type = 'EventInstance'
GROUP BY rbr1_ei.relationship_id
)
SELECT
ue.event_name,
TO_CHAR(ue.starts_at, 'Dy Mon DD, HH12:MI AM') as event_time,
ue.location,
CASE ue.approval_status
WHEN 'A' THEN '✓ Approved'
WHEN 'P' THEN '⚠ Pending'
WHEN 'R' THEN '✗ Rejected'
ELSE '○ No Approval Required'
END as approval_status,
COALESCE(er.resource_count, 0) as resources_needed,
COALESCE(er.resources_needed, 'No resources booked') as resource_list,
COALESCE(ec.conflicting_events, 0) as potential_conflicts,
EXTRACT(DAY FROM (ue.starts_at - CURRENT_TIMESTAMP)) as days_until_event,
CASE
WHEN EXTRACT(DAY FROM (ue.starts_at - CURRENT_TIMESTAMP)) <= 2 THEN 'URGENT'
WHEN EXTRACT(DAY FROM (ue.starts_at - CURRENT_TIMESTAMP)) <= 7 THEN 'This Week'
ELSE 'Next Week'
END as priority
FROM upcoming_events ue
LEFT JOIN event_resources er ON ue.event_instance_id = er.event_instance_id
LEFT JOIN event_conflicts ec ON ue.event_instance_id = ec.event_instance_id
ORDER BY ue.starts_at;
```
### Recurring Event Consistency Report
```sql theme={null}
-- Monitor Recurring Events for Irregularities
WITH recurring_events AS (
SELECT
e.event_id,
e.name,
ei.recurrence_description,
ei.starts_at,
ei.location,
LAG(ei.starts_at) OVER (PARTITION BY e.event_id ORDER BY ei.starts_at) as prev_start,
LAG(ei.location) OVER (PARTITION BY e.event_id ORDER BY ei.starts_at) as prev_location,
EXTRACT(DOW FROM ei.starts_at) as day_of_week,
EXTRACT(HOUR FROM ei.starts_at) as hour_of_day
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.recurrence <> 'None'
AND ei.starts_at >= CURRENT_DATE - INTERVAL '90 days'
),
event_patterns AS (
SELECT
event_id,
name,
recurrence_description,
COUNT(*) as instance_count,
COUNT(DISTINCT location) as location_variations,
COUNT(DISTINCT day_of_week) as day_variations,
COUNT(DISTINCT hour_of_day) as time_variations,
MODE() WITHIN GROUP (ORDER BY location) as usual_location,
MODE() WITHIN GROUP (ORDER BY day_of_week) as usual_day,
MODE() WITHIN GROUP (ORDER BY hour_of_day) as usual_hour,
AVG(EXTRACT(EPOCH FROM (starts_at - prev_start))/86400) as avg_days_between
FROM recurring_events
WHERE prev_start IS NOT NULL
GROUP BY event_id, name, recurrence_description
)
SELECT
name as event_name,
recurrence_description,
instance_count as occurrences_last_90_days,
CASE
WHEN location_variations > 1 THEN location_variations || ' different locations'
ELSE 'Consistent location'
END as location_consistency,
CASE
WHEN day_variations > 1 THEN day_variations || ' different days'
ELSE 'Consistent day'
END as day_consistency,
CASE
WHEN time_variations > 1 THEN time_variations || ' different times'
ELSE 'Consistent time'
END as time_consistency,
usual_location,
ROUND(avg_days_between, 1) as avg_interval_days,
CASE
WHEN location_variations > 1 OR day_variations > 1 OR time_variations > 1
THEN 'Review needed'
ELSE 'Consistent'
END as status
FROM event_patterns
WHERE instance_count > 1
ORDER BY
CASE
WHEN location_variations > 1 OR day_variations > 1 OR time_variations > 1
THEN 0 ELSE 1
END,
name;
```
## Resource Utilization Reports
### Non-Room Resource Usage Analysis
```sql theme={null}
-- Resource Utilization and Demand Report
WITH resource_usage AS (
SELECT
r.resource_id,
r.name as resource_name,
r.quantity as total_quantity,
r.serial_number,
DATE_TRUNC('month', rb.starts_at) as month,
COUNT(DISTINCT rb.resource_booking_id) as times_booked,
SUM(rb.quantity) as total_quantity_booked,
SUM(EXTRACT(EPOCH FROM (rb.ends_at - rb.starts_at))/3600) as hours_used,
COUNT(DISTINCT eir.relationship_id) as unique_events
FROM planning_center.calendar_resources r
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.relationship_id = r.resource_id AND rbr.relationship_type = 'Resource'
LEFT JOIN planning_center.calendar_resource_bookings rb
ON rb.resource_booking_id = rbr.resource_booking_id
AND rb.starts_at >= CURRENT_DATE - INTERVAL '6 months'
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr_ei
ON rbr_ei.resource_booking_id = rb.resource_booking_id AND rbr_ei.relationship_type = 'EventInstance'
LEFT JOIN planning_center.calendar_event_instances ei
ON ei.event_instance_id = rbr_ei.relationship_id
LEFT JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
WHERE r.kind = 'Resource'
GROUP BY r.resource_id, r.name, r.quantity, r.serial_number,
DATE_TRUNC('month', rb.starts_at)
),
resource_summary AS (
SELECT
resource_name,
total_quantity,
serial_number,
COUNT(DISTINCT month) as months_used,
SUM(times_booked) as total_bookings,
AVG(times_booked) as avg_monthly_bookings,
SUM(hours_used) as total_hours,
AVG(total_quantity_booked) as avg_quantity_per_booking,
SUM(unique_events) as events_supported
FROM resource_usage
GROUP BY resource_id, resource_name, total_quantity, serial_number
)
SELECT
resource_name,
total_quantity as available_qty,
COALESCE(serial_number, 'N/A') as serial,
total_bookings,
ROUND(avg_monthly_bookings, 1) as avg_bookings_per_month,
ROUND(total_hours, 1) as total_hours_used,
ROUND(avg_quantity_per_booking, 1) as avg_qty_per_use,
events_supported,
CASE
WHEN avg_monthly_bookings > 20 THEN 'High Demand'
WHEN avg_monthly_bookings > 10 THEN 'Medium Demand'
WHEN avg_monthly_bookings > 0 THEN 'Low Demand'
ELSE 'Unused'
END as demand_level,
CASE
WHEN avg_quantity_per_booking > total_quantity * 0.8 THEN 'Consider increasing inventory'
WHEN total_bookings = 0 THEN 'Consider removing from inventory'
ELSE 'Adequate'
END as recommendation
FROM resource_summary
ORDER BY total_bookings DESC;
```
### Peak Usage Heatmap Data
```sql theme={null}
-- Generate heatmap data for resource usage patterns
WITH usage_grid AS (
SELECT
TO_CHAR(rb.starts_at, 'FMDay') as day_name,
EXTRACT(DOW FROM rb.starts_at) as day_num,
EXTRACT(HOUR FROM rb.starts_at) as hour,
r.kind as resource_type,
COUNT(DISTINCT rb.resource_booking_id) as bookings,
COUNT(DISTINCT rbr.relationship_id) as resources_used
FROM planning_center.calendar_resource_bookings rb
JOIN planning_center.calendar_resource_bookings_relationships rbr
ON rbr.resource_booking_id = rb.resource_booking_id AND rbr.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr.relationship_id
WHERE rb.starts_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY TO_CHAR(rb.starts_at, 'FMDay'),
EXTRACT(DOW FROM rb.starts_at),
EXTRACT(HOUR FROM rb.starts_at),
r.kind
),
normalized_usage AS (
SELECT
day_name,
day_num,
hour,
hour || ':00' as time_slot,
resource_type,
bookings,
resources_used,
MAX(bookings) OVER (PARTITION BY resource_type) as max_bookings,
ROUND(bookings * 100.0 / NULLIF(MAX(bookings) OVER (PARTITION BY resource_type), 0), 0) as intensity_pct
FROM usage_grid
)
SELECT
resource_type,
day_name,
time_slot,
bookings,
resources_used,
intensity_pct,
CASE
WHEN intensity_pct >= 80 THEN '🔴 Peak'
WHEN intensity_pct >= 50 THEN '🟡 High'
WHEN intensity_pct >= 20 THEN '🔵 Medium'
WHEN intensity_pct > 0 THEN '⚪ Low'
ELSE '⚫ None'
END as usage_level,
REPEAT('█', (intensity_pct / 10)::int) as intensity_bar
FROM normalized_usage
WHERE hour BETWEEN 6 AND 22 -- Business hours only
ORDER BY resource_type, day_num, hour;
```
## Conflict and Compliance Reports
### Resource Conflict Report
```sql theme={null}
-- Comprehensive Conflict Detection and Resolution Report
WITH conflicts AS (
SELECT
r.name as resource_name,
r.kind as resource_type,
e1.name as event1,
rb1.starts_at as event1_start,
rb1.ends_at as event1_end,
e2.name as event2,
rb2.starts_at as event2_start,
rb2.ends_at as event2_end,
EXTRACT(EPOCH FROM (
LEAST(rb1.ends_at, rb2.ends_at) -
GREATEST(rb1.starts_at, rb2.starts_at)
))/3600 as overlap_hours,
CASE
WHEN e1.approval_status = 'A' AND e2.approval_status != 'A' THEN e1.name
WHEN e2.approval_status = 'A' AND e1.approval_status != 'A' THEN e2.name
WHEN e1.created_at < e2.created_at THEN e1.name
ELSE e2.name
END as priority_event
FROM planning_center.calendar_resource_bookings rb1
-- Join rb1 to its resource via relationship table
JOIN planning_center.calendar_resource_bookings_relationships rbr1_res
ON rbr1_res.resource_booking_id = rb1.resource_booking_id AND rbr1_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resources r ON r.resource_id = rbr1_res.relationship_id
-- Find rb2 sharing the same resource
JOIN planning_center.calendar_resource_bookings_relationships rbr2_res
ON rbr2_res.relationship_id = rbr1_res.relationship_id AND rbr2_res.relationship_type = 'Resource'
JOIN planning_center.calendar_resource_bookings rb2
ON rb2.resource_booking_id = rbr2_res.resource_booking_id
AND rb1.resource_booking_id < rb2.resource_booking_id
AND rb1.starts_at < rb2.ends_at
AND rb1.ends_at > rb2.starts_at
-- Join rb1 to event via relationship tables
JOIN planning_center.calendar_resource_bookings_relationships rbr1_ei
ON rbr1_ei.resource_booking_id = rb1.resource_booking_id AND rbr1_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei1 ON ei1.event_instance_id = rbr1_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir1
ON eir1.event_instance_id = ei1.event_instance_id AND eir1.relationship_type = 'Event'
JOIN planning_center.calendar_events e1 ON e1.event_id = eir1.relationship_id
-- Join rb2 to event via relationship tables
JOIN planning_center.calendar_resource_bookings_relationships rbr2_ei
ON rbr2_ei.resource_booking_id = rb2.resource_booking_id AND rbr2_ei.relationship_type = 'EventInstance'
JOIN planning_center.calendar_event_instances ei2 ON ei2.event_instance_id = rbr2_ei.relationship_id
JOIN planning_center.calendar_event_instances_relationships eir2
ON eir2.event_instance_id = ei2.event_instance_id AND eir2.relationship_type = 'Event'
JOIN planning_center.calendar_events e2 ON e2.event_id = eir2.relationship_id
WHERE rb1.starts_at >= CURRENT_DATE
)
SELECT
resource_name,
resource_type,
TO_CHAR(event1_start, 'Mon DD HH12:MI AM') as conflict_time,
event1 || ' vs ' || event2 as conflicting_events,
ROUND(overlap_hours, 1) || ' hours' as overlap_duration,
priority_event as suggested_priority,
CASE
WHEN overlap_hours >= 2 THEN 'Critical'
WHEN overlap_hours >= 1 THEN 'Major'
ELSE 'Minor'
END as severity
FROM conflicts
ORDER BY event1_start, severity DESC;
```
### Approval Workflow Status
```sql theme={null}
-- Event Approval Workflow Dashboard
WITH approval_timeline AS (
SELECT
e.event_id,
e.name,
e.created_at,
e.updated_at,
e.approval_status,
e.percent_approved,
e.percent_rejected,
ei.starts_at as event_date,
EXTRACT(EPOCH FROM (ei.starts_at - e.created_at))/86400 as days_advance_notice,
EXTRACT(EPOCH FROM (COALESCE(e.updated_at, CURRENT_TIMESTAMP) - e.created_at))/3600 as hours_to_decision,
EXTRACT(EPOCH FROM (ei.starts_at - CURRENT_TIMESTAMP))/86400 as days_until_event
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
WHERE ei.starts_at >= CURRENT_DATE - INTERVAL '30 days'
AND ei.starts_at <= CURRENT_DATE + INTERVAL '60 days'
),
approval_categories AS (
SELECT
CASE approval_status
WHEN 'A' THEN 'Approved'
WHEN 'P' THEN 'Pending'
WHEN 'R' THEN 'Rejected'
ELSE 'No Approval Required'
END as status,
COUNT(*) as event_count,
AVG(days_advance_notice) as avg_advance_notice,
AVG(CASE WHEN approval_status IN ('A', 'R') THEN hours_to_decision END) as avg_decision_hours,
MIN(days_until_event) FILTER (WHERE approval_status = 'P') as most_urgent_pending_days
FROM approval_timeline
GROUP BY approval_status
)
SELECT
status,
event_count,
ROUND(avg_advance_notice, 1) as avg_days_advance_notice,
ROUND(avg_decision_hours, 1) as avg_hours_to_approve,
COALESCE(ROUND(most_urgent_pending_days, 1)::text, 'N/A') as days_to_most_urgent,
CASE
WHEN status = 'Pending' AND most_urgent_pending_days < 7 THEN '⚠️ Urgent Review Needed'
WHEN status = 'Pending' THEN '📋 Review Required'
WHEN status = 'Approved' THEN '✅ Complete'
WHEN status = 'Rejected' THEN '❌ Denied'
ELSE '➖ N/A'
END as action_required
FROM approval_categories
ORDER BY
CASE status
WHEN 'Pending' THEN 1
WHEN 'Approved' THEN 2
WHEN 'No Approval Required' THEN 3
WHEN 'Rejected' THEN 4
END;
```
## Ministry Planning Reports
### Annual Ministry Calendar
```sql theme={null}
-- Annual Ministry Planning Calendar
WITH ministry_events AS (
SELECT
DATE_TRUNC('month', ei.starts_at) as month,
e.name,
ei.starts_at,
ei.ends_at,
ei.all_day_event,
ei.recurrence,
ei.recurrence_description,
e.featured,
t.name as ministry_tag
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
LEFT JOIN planning_center.calendar_events_relationships er
ON e.event_id = er.event_id AND er.relationship_type = 'Tag'
LEFT JOIN planning_center.calendar_tags t ON er.relationship_id = t.tag_id
WHERE ei.starts_at >= DATE_TRUNC('year', CURRENT_DATE)
AND ei.starts_at < DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 year'
),
monthly_summary AS (
SELECT
TO_CHAR(month, 'FMMonth') as month_name,
EXTRACT(MONTH FROM month) as month_num,
COUNT(DISTINCT name) as unique_events,
COUNT(*) as total_occurrences,
COUNT(DISTINCT DATE(starts_at)) as event_days,
COUNT(*) FILTER (WHERE featured = true) as featured_events,
COUNT(*) FILTER (WHERE recurrence <> 'None') as recurring_events,
STRING_AGG(DISTINCT ministry_tag, ', ') as ministries_active
FROM ministry_events
GROUP BY month, TO_CHAR(month, 'FMMonth'), EXTRACT(MONTH FROM month)
),
key_events AS (
SELECT
EXTRACT(MONTH FROM starts_at) as month_num,
STRING_AGG(
name || ' (' || TO_CHAR(starts_at, 'DD') || ')',
', ' ORDER BY starts_at
) FILTER (WHERE featured = true) as featured_list
FROM ministry_events
WHERE featured = true
GROUP BY EXTRACT(MONTH FROM starts_at)
)
SELECT
ms.month_name,
ms.unique_events,
ms.total_occurrences,
ms.event_days,
ms.featured_events,
ms.recurring_events,
COALESCE(ms.ministries_active, 'None tagged') as active_ministries,
COALESCE(ke.featured_list, 'No featured events') as key_events
FROM monthly_summary ms
LEFT JOIN key_events ke ON ms.month_num = ke.month_num
ORDER BY ms.month_num;
```
### Ministry Participation Trends
```sql theme={null}
-- Track Ministry Engagement Through Events
WITH ministry_metrics AS (
SELECT
tg.name as ministry_group,
t.name as ministry_tag,
DATE_TRUNC('quarter', ei.starts_at) as quarter,
COUNT(DISTINCT e.event_id) as unique_events,
COUNT(DISTINCT ei.event_instance_id) as total_instances,
COUNT(DISTINCT ei.location) as locations_used,
SUM(CASE WHEN e.visible_in_church_center THEN 1 ELSE 0 END) as public_events
FROM planning_center.calendar_tags t
LEFT JOIN planning_center.calendar_tag_groups_relationships tgr
ON tgr.relationship_id = t.tag_id AND tgr.relationship_type = 'Tag'
LEFT JOIN planning_center.calendar_tag_groups tg ON tg.tag_group_id = tgr.tag_group_id
LEFT JOIN planning_center.calendar_events_relationships er
ON t.tag_id = er.relationship_id AND er.relationship_type = 'Tag'
LEFT JOIN planning_center.calendar_events e ON er.event_id = e.event_id
LEFT JOIN planning_center.calendar_event_instances_relationships eir
ON eir.relationship_id = e.event_id AND eir.relationship_type = 'Event'
LEFT JOIN planning_center.calendar_event_instances ei ON ei.event_instance_id = eir.event_instance_id
WHERE ei.starts_at >= CURRENT_DATE - INTERVAL '1 year'
GROUP BY tg.name, t.name, DATE_TRUNC('quarter', ei.starts_at)
),
ministry_trends AS (
SELECT
ministry_group,
ministry_tag,
quarter,
TO_CHAR(quarter, 'Q#YYYY') as quarter_label,
unique_events,
total_instances,
locations_used,
public_events,
LAG(total_instances, 1) OVER (
PARTITION BY ministry_tag ORDER BY quarter
) as prev_quarter_instances
FROM ministry_metrics
WHERE quarter IS NOT NULL
)
SELECT
COALESCE(ministry_group, 'Uncategorized') as ministry_area,
ministry_tag,
quarter_label,
unique_events,
total_instances,
locations_used,
public_events,
CASE
WHEN prev_quarter_instances IS NULL THEN 'New'
WHEN total_instances > prev_quarter_instances THEN '↑ Growing'
WHEN total_instances < prev_quarter_instances THEN '↓ Declining'
ELSE '→ Stable'
END as trend,
ROUND(((total_instances - prev_quarter_instances) * 100.0 /
NULLIF(prev_quarter_instances, 0)), 1) as growth_pct
FROM ministry_trends
WHERE total_instances > 0
ORDER BY ministry_area, ministry_tag, quarter DESC;
```
## Export-Ready Reports
### CSV Export for Facility Schedule
```sql theme={null}
-- Export-ready facility schedule for the next month
SELECT
TO_CHAR(ei.starts_at, 'YYYY-MM-DD') as date,
TO_CHAR(ei.starts_at, 'HH24:MI') as start_time,
TO_CHAR(ei.ends_at, 'HH24:MI') as end_time,
e.name as event_name,
ei.location as primary_location,
STRING_AGG(DISTINCT r.name, '; ') as resources_booked,
e.description as event_description,
CASE e.approval_status
WHEN 'A' THEN 'Approved'
WHEN 'P' THEN 'Pending'
WHEN 'R' THEN 'Rejected'
ELSE 'No Approval Required'
END as status,
CASE
WHEN ei.all_day_event THEN 'Yes'
ELSE 'No'
END as all_day
FROM planning_center.calendar_event_instances ei
JOIN planning_center.calendar_event_instances_relationships eir
ON eir.event_instance_id = ei.event_instance_id AND eir.relationship_type = 'Event'
JOIN planning_center.calendar_events e ON e.event_id = eir.relationship_id
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr_ei
ON rbr_ei.relationship_id = ei.event_instance_id AND rbr_ei.relationship_type = 'EventInstance'
LEFT JOIN planning_center.calendar_resource_bookings rb
ON rb.resource_booking_id = rbr_ei.resource_booking_id
LEFT JOIN planning_center.calendar_resource_bookings_relationships rbr_res
ON rbr_res.resource_booking_id = rb.resource_booking_id AND rbr_res.relationship_type = 'Resource'
LEFT JOIN planning_center.calendar_resources r ON r.resource_id = rbr_res.relationship_id
WHERE ei.starts_at >= CURRENT_DATE
AND ei.starts_at < CURRENT_DATE + INTERVAL '30 days'
GROUP BY e.event_id, e.name, e.description, e.approval_status,
ei.event_instance_id, ei.starts_at, ei.ends_at, ei.location, ei.all_day_event
ORDER BY ei.starts_at;
```
## Report Best Practices
### 1. Performance Optimization
* Use date filters early in WHERE clauses
* Create indexes on frequently filtered columns
* Consider materialized views for complex reports
### 2. Data Accuracy
* Always check approval\_status for confirmed events
* Account for all-day events in date calculations
* Handle NULL values in optional fields
### 3. User Experience
* Include visual indicators (emojis/symbols) for status
* Format dates and times for readability
* Provide actionable recommendations
### 4. Report Scheduling
* Executive dashboards: Weekly
* Utilization reports: Monthly
* Conflict reports: Daily or as-needed
* Planning reports: Quarterly
## Next Steps
* Review the [Data Model](/planning-center/calendar/data-model) for complete table documentation
* Return to [Advanced Queries](/planning-center/calendar/advanced-queries) for more query techniques
* Check [Basic Queries](/planning-center/calendar/basic-queries) for fundamental concepts
# Advanced Planning Center Check-ins Queries
Source: https://docs.getparable.io/planning-center/check-ins/advanced-queries
Advanced Check-ins SQL using multi-table joins and window functions to analyze attendance retention and volunteer-to-attendee ratios.
This guide provides complex SQL queries for deeper analysis of your Planning Center Check-ins data. These queries use multiple table joins, window functions, and advanced SQL features to answer sophisticated ministry questions.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Check-ins module live in the `planning_center` schema. Always prefix table names with `planning_center.` in advanced queries.
✅ CORRECT: `SELECT * FROM planning_center.checkins_check_ins`
❌ INCORRECT: `SELECT * FROM checkins_check_ins`
### Row Level Security (RLS)
Row Level Security automatically enforces:
* **tenant\_organization\_id** – results limited to your organization
* **system\_status** – active records returned by default
**Do not duplicate these filters**—RLS already applies them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Direct your filters toward ministry-specific attendance and volunteer logic while trusting RLS for tenancy and status.
## Attendance Analytics
### Weekly Attendance Trends with Growth Metrics
```sql theme={null}
-- Calculate week-over-week attendance growth with moving averages
WITH weekly_attendance AS (
SELECT
DATE_TRUNC('week', created_at) as week_start,
COUNT(DISTINCT check_in_id) as total_attendance,
COUNT(DISTINCT CASE WHEN kind = 'Regular' THEN check_in_id END) as regular_attendance,
COUNT(DISTINCT CASE WHEN kind = 'Guest' THEN check_in_id END) as guest_attendance,
COUNT(DISTINCT CASE WHEN kind = 'Volunteer' THEN check_in_id END) as volunteer_attendance
FROM planning_center.checkins_check_ins
WHERE created_at >= CURRENT_DATE - INTERVAL '12 weeks'
GROUP BY DATE_TRUNC('week', created_at)
),
attendance_with_growth AS (
SELECT
week_start,
total_attendance,
regular_attendance,
guest_attendance,
volunteer_attendance,
LAG(total_attendance, 1) OVER (ORDER BY week_start) as prev_week_attendance,
AVG(total_attendance) OVER (
ORDER BY week_start
ROWS BETWEEN 3 PRECEDING AND CURRENT ROW
) as four_week_avg
FROM weekly_attendance
)
SELECT
TO_CHAR(week_start, 'YYYY-MM-DD') as week,
total_attendance,
regular_attendance,
guest_attendance,
volunteer_attendance,
ROUND(four_week_avg, 0) as rolling_4wk_avg,
CASE
WHEN prev_week_attendance > 0 THEN
ROUND(((total_attendance - prev_week_attendance)::NUMERIC / prev_week_attendance) * 100, 1)
ELSE NULL
END as week_over_week_change_pct
FROM attendance_with_growth
ORDER BY week_start DESC;
```
### Service Time Optimization Analysis
```sql theme={null}
-- Analyze attendance distribution across service times to identify optimization opportunities
WITH service_attendance AS (
SELECT
et.event_time_id,
et.starts_at,
e.name as event_name,
TO_CHAR(et.starts_at, 'FMDay') as day_of_week,
TO_CHAR(et.starts_at, 'HH12:MI AM') as service_time,
COUNT(DISTINCT c.check_in_id) as attendance
FROM planning_center.checkins_event_times et
-- Join to events via eventtime_relationships (no direct event_id on event_times)
JOIN planning_center.checkins_event_times_relationships etr
ON et.event_time_id = etr.event_time_id
AND etr.relationship_type = 'Event'
JOIN planning_center.checkins_events e
ON etr.relationship_id = e.event_id
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON et.event_time_id = cr.relationship_id
AND cr.relationship_type = 'EventTime'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
WHERE et.starts_at >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY et.event_time_id, et.starts_at, e.name
),
service_stats AS (
SELECT
event_name,
day_of_week,
service_time,
AVG(attendance) as avg_attendance,
MIN(attendance) as min_attendance,
MAX(attendance) as max_attendance,
STDDEV(attendance) as attendance_stddev,
COUNT(*) as service_count
FROM service_attendance
GROUP BY event_name, day_of_week, service_time
)
SELECT
event_name,
day_of_week,
service_time,
ROUND(avg_attendance, 0) as avg_attendance,
min_attendance,
max_attendance,
ROUND(attendance_stddev, 1) as variance,
service_count as times_held,
CASE
WHEN attendance_stddev > avg_attendance * 0.3 THEN 'High variance - investigate'
WHEN avg_attendance < 50 THEN 'Consider combining services'
WHEN max_attendance > avg_attendance * 1.5 THEN 'Capacity issues possible'
ELSE 'Stable'
END as recommendation
FROM service_stats
ORDER BY event_name,
CASE day_of_week
WHEN 'Sunday' THEN 1
WHEN 'Saturday' THEN 2
WHEN 'Wednesday' THEN 3
ELSE 4
END,
service_time;
```
## Volunteer Analytics
### Volunteer Reliability Score
```sql theme={null}
-- Calculate volunteer reliability based on check-in patterns
WITH volunteer_schedule AS (
SELECT
p.person_id,
p.first_name,
p.last_name,
DATE_TRUNC('week', c.created_at) as week,
COUNT(DISTINCT DATE(c.created_at)) as days_served
FROM planning_center.checkins_people p
JOIN planning_center.checkins_check_ins_relationships cr
ON p.person_id = cr.relationship_id
AND cr.relationship_type = 'Person'
JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND c.kind = 'Volunteer'
WHERE c.created_at >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY p.person_id, p.first_name, p.last_name, DATE_TRUNC('week', c.created_at)
),
volunteer_stats AS (
SELECT
person_id,
first_name,
last_name,
COUNT(DISTINCT week) as weeks_served,
SUM(days_served) as total_days_served,
AVG(days_served) as avg_days_per_week
FROM volunteer_schedule
GROUP BY person_id, first_name, last_name
)
SELECT
first_name,
last_name,
weeks_served,
total_days_served,
ROUND(avg_days_per_week, 1) as avg_days_per_week,
ROUND((weeks_served::NUMERIC / 13) * 100, 0) as consistency_pct,
CASE
WHEN weeks_served >= 10 AND avg_days_per_week >= 1 THEN 'Highly Reliable'
WHEN weeks_served >= 6 THEN 'Reliable'
WHEN weeks_served >= 3 THEN 'Occasional'
ELSE 'New or Inactive'
END as reliability_rating
FROM volunteer_stats
ORDER BY weeks_served DESC, total_days_served DESC;
```
### Volunteer-to-Child Ratio Analysis with Alerts
```sql theme={null}
-- Complex ratio analysis with safety thresholds and recommendations
WITH current_checkins AS (
SELECT
l.location_id,
l.name as location_name,
l.attendees_per_volunteer as required_ratio,
l.min_volunteers,
l.max_occupancy,
COUNT(DISTINCT CASE WHEN c.kind = 'Regular' THEN c.check_in_id END) as child_count,
COUNT(DISTINCT CASE WHEN c.kind = 'Volunteer' THEN c.check_in_id END) as volunteer_count
FROM planning_center.checkins_locations l
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON l.location_id = cr.relationship_id
AND cr.relationship_type = 'Location'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND DATE(c.created_at) = CURRENT_DATE
AND c.checked_out_at IS NULL
WHERE l.child_or_adult = 'C'
AND l.kind = 'Location'
GROUP BY l.location_id, l.name, l.attendees_per_volunteer, l.min_volunteers, l.max_occupancy
),
ratio_analysis AS (
SELECT
location_name,
child_count,
volunteer_count,
required_ratio,
min_volunteers,
max_occupancy,
CASE
WHEN volunteer_count = 0 THEN NULL
ELSE ROUND(child_count::NUMERIC / volunteer_count, 1)
END as actual_ratio,
CASE
WHEN COALESCE(required_ratio, 0) > 0 AND volunteer_count > 0 THEN
CEIL(child_count::NUMERIC / required_ratio)
ELSE min_volunteers
END as volunteers_needed
FROM current_checkins
)
SELECT
location_name,
child_count,
volunteer_count,
COALESCE(actual_ratio::TEXT, 'No volunteers') as actual_ratio,
required_ratio,
volunteers_needed,
volunteer_count - volunteers_needed as volunteer_surplus_deficit,
CASE
WHEN volunteer_count = 0 AND child_count > 0 THEN '🚨 CRITICAL: No volunteers present!'
WHEN COALESCE(min_volunteers, 0) > 0 AND volunteer_count < min_volunteers THEN '⚠️ Below minimum volunteers'
WHEN volunteers_needed > volunteer_count THEN '⚠️ Need more volunteers'
WHEN actual_ratio > required_ratio * 1.5 THEN '⚠️ Ratio exceeds safe limit'
WHEN child_count > COALESCE(max_occupancy, 999) THEN '⚠️ Over capacity'
WHEN volunteer_count > volunteers_needed + 2 THEN '✓ Overstaffed (reassign possible)'
ELSE '✓ Properly staffed'
END as status
FROM ratio_analysis
WHERE child_count > 0 OR volunteer_count > 0
ORDER BY
CASE
WHEN volunteer_count = 0 AND child_count > 0 THEN 1
WHEN volunteer_count < volunteers_needed THEN 2
ELSE 3
END,
location_name;
```
## Guest Retention Analysis
### First-Time Guest Return Rate
```sql theme={null}
-- Track whether first-time guests return within different time windows
WITH first_visits AS (
SELECT
p.person_id,
p.first_name,
p.last_name,
MIN(c.created_at) as first_visit_date
FROM planning_center.checkins_people p
JOIN planning_center.checkins_check_ins_relationships cr
ON p.person_id = cr.relationship_id
AND cr.relationship_type = 'Person'
JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
WHERE c.one_time_guest = true
OR c.kind = 'Guest'
GROUP BY p.person_id, p.first_name, p.last_name
),
return_visits AS (
SELECT
fv.person_id,
fv.first_name,
fv.last_name,
fv.first_visit_date,
COUNT(DISTINCT DATE(c.created_at)) as total_visits,
MAX(c.created_at) as last_visit_date,
COUNT(DISTINCT CASE
WHEN c.created_at > fv.first_visit_date
AND c.created_at <= fv.first_visit_date + INTERVAL '7 days'
THEN DATE(c.created_at)
END) as visits_within_1_week,
COUNT(DISTINCT CASE
WHEN c.created_at > fv.first_visit_date
AND c.created_at <= fv.first_visit_date + INTERVAL '30 days'
THEN DATE(c.created_at)
END) as visits_within_1_month,
COUNT(DISTINCT CASE
WHEN c.created_at > fv.first_visit_date
AND c.created_at <= fv.first_visit_date + INTERVAL '90 days'
THEN DATE(c.created_at)
END) as visits_within_3_months
FROM first_visits fv
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON fv.person_id = cr.relationship_id
AND cr.relationship_type = 'Person'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
WHERE fv.first_visit_date >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY fv.person_id, fv.first_name, fv.last_name, fv.first_visit_date
)
SELECT
DATE_TRUNC('month', first_visit_date) as cohort_month,
COUNT(*) as total_first_time_guests,
COUNT(CASE WHEN visits_within_1_week > 0 THEN 1 END) as returned_within_1_week,
COUNT(CASE WHEN visits_within_1_month > 0 THEN 1 END) as returned_within_1_month,
COUNT(CASE WHEN visits_within_3_months > 0 THEN 1 END) as returned_within_3_months,
ROUND(COUNT(CASE WHEN visits_within_1_week > 0 THEN 1 END)::NUMERIC / COUNT(*) * 100, 1) as week_return_rate,
ROUND(COUNT(CASE WHEN visits_within_1_month > 0 THEN 1 END)::NUMERIC / COUNT(*) * 100, 1) as month_return_rate,
ROUND(COUNT(CASE WHEN visits_within_3_months > 0 THEN 1 END)::NUMERIC / COUNT(*) * 100, 1) as quarter_return_rate
FROM return_visits
GROUP BY DATE_TRUNC('month', first_visit_date)
ORDER BY cohort_month DESC;
```
## Location Hierarchy Analysis
### Multi-Level Location Utilization
```sql theme={null}
-- Analyze utilization across location hierarchy (building > floor > room)
-- Note: locations also have a direct parent_id column, but this query uses
-- the relationship table for consistency with the generic relationship pattern
WITH RECURSIVE location_hierarchy AS (
-- Base case: top-level locations (those without a Parent relationship)
SELECT
l.location_id,
l.name,
l.kind,
l.max_occupancy,
0 as level,
l.name::TEXT as path
FROM planning_center.checkins_locations l
WHERE NOT EXISTS (
SELECT 1
FROM planning_center.checkins_locations_relationships lr
WHERE lr.location_id = l.location_id
AND lr.relationship_type = 'Parent'
)
UNION ALL
-- Recursive case: child locations
SELECT
l.location_id,
l.name,
l.kind,
l.max_occupancy,
lh.level + 1,
lh.path || ' > ' || l.name
FROM planning_center.checkins_locations l
JOIN planning_center.checkins_locations_relationships lr
ON l.location_id = lr.location_id
AND lr.relationship_type = 'Parent'
JOIN location_hierarchy lh ON lr.relationship_id = lh.location_id
),
location_attendance AS (
SELECT
lh.location_id,
lh.path,
lh.level,
lh.kind,
lh.max_occupancy,
COUNT(DISTINCT c.check_in_id) as current_attendance
FROM location_hierarchy lh
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON lh.location_id = cr.relationship_id
AND cr.relationship_type = 'Location'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND DATE(c.created_at) = CURRENT_DATE
AND c.checked_out_at IS NULL
GROUP BY lh.location_id, lh.path, lh.level, lh.kind, lh.max_occupancy
)
SELECT
REPEAT(' ', level) || SPLIT_PART(path, ' > ', level + 1) AS location,
kind AS type,
current_attendance,
max_occupancy,
CASE
-- 0 means "no limit set", so treat it the same as a missing value
WHEN COALESCE(max_occupancy, 0) = 0 THEN '-'
ELSE ROUND((current_attendance::NUMERIC / max_occupancy) * 100, 0)::TEXT || '%'
END AS utilization,
CASE
WHEN COALESCE(max_occupancy, 0) = 0 THEN 'No limit set'
WHEN current_attendance >= max_occupancy THEN 'FULL'
WHEN current_attendance >= max_occupancy * 0.8 THEN 'Nearly Full'
ELSE 'Available'
END AS status
FROM location_attendance
ORDER BY path;
```
## Event Period Analysis
### Peak Attendance Times with Capacity Planning
```sql theme={null}
-- Identify peak times and capacity constraints across event periods
-- Note: event_periods also have a direct event_id column, but this query uses
-- relationship tables for consistency with the generic relationship pattern.
-- Similarly, checkin_times have a direct check_in_id column but are joined via
-- checkins_check_in_times_relationships with relationship_type = 'CheckIn'.
WITH period_metrics AS (
SELECT
ep.event_period_id,
ep.starts_at,
ep.ends_at,
e.name as event_name,
TO_CHAR(ep.starts_at, 'FMDay') as day_of_week,
TO_CHAR(ep.starts_at, 'HH12:MI AM') as start_time,
COUNT(DISTINCT c.check_in_id) as total_checkins,
COUNT(DISTINCT CASE WHEN c.kind = 'Regular' THEN c.check_in_id END) as regular_checkins,
COUNT(DISTINCT CASE WHEN c.kind = 'Guest' THEN c.check_in_id END) as guest_checkins,
COUNT(DISTINCT CASE WHEN c.kind = 'Volunteer' THEN c.check_in_id END) as volunteer_checkins,
COUNT(DISTINCT ct.check_in_time_id) as actual_check_in_times
FROM planning_center.checkins_event_periods ep
-- Join to event via event_period_relationships (no direct event_id on event_periods)
JOIN planning_center.checkins_event_periods_relationships epr
ON ep.event_period_id = epr.event_period_id
AND epr.relationship_type = 'Event'
JOIN planning_center.checkins_events e
ON epr.relationship_id = e.event_id
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON ep.event_period_id = cr.relationship_id
AND cr.relationship_type = 'EventPeriod'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
-- Join to checkin_times via relationship table (direct check_in_id also exists)
LEFT JOIN planning_center.checkins_check_in_times_relationships ctr
ON c.check_in_id = ctr.relationship_id
AND ctr.relationship_type = 'CheckIn'
LEFT JOIN planning_center.checkins_check_in_times ct
ON ctr.check_in_time_id = ct.check_in_time_id
WHERE ep.starts_at >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY ep.event_period_id, ep.starts_at, ep.ends_at, e.name
),
period_analysis AS (
SELECT
event_name,
day_of_week,
start_time,
AVG(total_checkins) as avg_attendance,
MAX(total_checkins) as peak_attendance,
MIN(total_checkins) as min_attendance,
AVG(guest_checkins) as avg_guests,
AVG(volunteer_checkins) as avg_volunteers,
COUNT(*) as period_count
FROM period_metrics
GROUP BY event_name, day_of_week, start_time
)
SELECT
event_name,
day_of_week,
start_time,
ROUND(avg_attendance, 0) as avg_attendance,
peak_attendance,
min_attendance,
ROUND(avg_guests, 1) as avg_guests,
ROUND(avg_volunteers, 1) as avg_volunteers,
period_count as times_held,
peak_attendance - ROUND(avg_attendance, 0) as peak_variance,
CASE
WHEN peak_attendance > avg_attendance * 1.3 THEN 'Prepare for ' || peak_attendance || ' attendees'
WHEN avg_volunteers < 5 THEN 'Consider recruiting more volunteers'
WHEN avg_guests > avg_attendance * 0.2 THEN 'High guest ratio - ensure welcome team'
ELSE 'Normal operations'
END as planning_note
FROM period_analysis
WHERE period_count >= 3 -- Only show recurring events
ORDER BY
CASE day_of_week
WHEN 'Sunday' THEN 1
WHEN 'Saturday' THEN 2
WHEN 'Wednesday' THEN 3
ELSE 4
END,
start_time;
```
## Family Unit Analysis
### Family Check-in Patterns
```sql theme={null}
-- Identify families checking in together based on matching last names and check-in times
WITH checkin_groups AS (
SELECT
c1.check_in_id as parent_checkin,
c1.first_name as parent_first_name,
c1.last_name as family_name,
c2.check_in_id as child_checkin,
c2.first_name as child_first_name,
c1.created_at as checkin_time,
ABS(EXTRACT(EPOCH FROM (c2.created_at - c1.created_at))) as seconds_apart
FROM planning_center.checkins_check_ins c1
JOIN planning_center.checkins_check_ins c2
ON c1.last_name = c2.last_name
AND c1.check_in_id != c2.check_in_id
AND DATE(c1.created_at) = DATE(c2.created_at)
AND ABS(EXTRACT(EPOCH FROM (c2.created_at - c1.created_at))) <= 300 -- Within 5 minutes
WHERE DATE(c1.created_at) = CURRENT_DATE
AND c1.kind IN ('Regular', 'Guest')
AND c2.kind IN ('Regular', 'Guest')
),
family_groups AS (
-- The self-join is symmetric: every check-in appears once as parent_checkin
-- and again as child_checkin. Counting both sides would double the family
-- size, so count one side only.
SELECT
family_name,
COUNT(DISTINCT parent_checkin) as family_size,
STRING_AGG(DISTINCT parent_first_name, ', ' ORDER BY parent_first_name) as family_members,
MIN(checkin_time) as first_checkin,
MAX(seconds_apart) as max_seconds_between_checkins
FROM checkin_groups
GROUP BY family_name
HAVING COUNT(DISTINCT parent_checkin) >= 2
)
SELECT
family_name,
family_size,
family_members,
TO_CHAR(first_checkin, 'HH12:MI AM') as checkin_time,
ROUND(max_seconds_between_checkins / 60.0, 1) as minutes_to_complete_checkin
FROM family_groups
ORDER BY family_size DESC, first_checkin;
```
## Station Performance Analysis
```sql theme={null}
-- Analyze check-in station usage and performance
-- Note: checkins_check_ins also has a direct checked_in_at_id column, but this
-- query uses the relationship table for consistency with the generic pattern
WITH station_metrics AS (
SELECT
s.station_id,
s.name as station_name,
s.mode as station_mode,
COUNT(DISTINCT c.check_in_id) as total_checkins,
COUNT(DISTINCT DATE_TRUNC('hour', c.created_at)) as active_hours,
MIN(c.created_at) as first_checkin,
MAX(c.created_at) as last_checkin
FROM planning_center.checkins_stations s
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON s.station_id = cr.relationship_id
AND cr.relationship_type = 'CheckedInAt'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND c.created_at >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY s.station_id, s.name, s.mode
),
station_performance AS (
SELECT
station_name,
station_mode,
total_checkins,
active_hours,
CASE
WHEN active_hours > 0 THEN ROUND(total_checkins::NUMERIC / active_hours, 1)
ELSE 0
END as avg_checkins_per_hour,
TO_CHAR(first_checkin, 'MM/DD HH12:MI AM') as first_use,
TO_CHAR(last_checkin, 'MM/DD HH12:MI AM') as last_use,
EXTRACT(EPOCH FROM (last_checkin - first_checkin)) / 3600 as total_hours_used
FROM station_metrics
)
SELECT
station_name,
station_mode,
total_checkins,
avg_checkins_per_hour,
ROUND(total_hours_used, 1) as total_hours_used,
first_use,
last_use,
CASE
WHEN avg_checkins_per_hour > 30 THEN 'High traffic - may need additional stations'
WHEN avg_checkins_per_hour < 5 AND total_checkins > 10 THEN 'Low utilization - consider relocating'
WHEN total_checkins = 0 THEN 'Unused - verify station is working'
ELSE 'Normal usage'
END as recommendation
FROM station_performance
ORDER BY total_checkins DESC;
```
## Next Steps
* Review [Reporting Examples](/planning-center/check-ins/reporting-examples) for production-ready reports
* Check the [Data Model](/planning-center/check-ins/data-model) for complete field documentation
* Return to [Basic Queries](/planning-center/check-ins/basic-queries) for simpler examples
# Basic Planning Center Check-ins Queries
Source: https://docs.getparable.io/planning-center/check-ins/basic-queries
Ready-to-run SQL for Planning Center Check-ins: who checked in today, counts by location, how full each room is, and guest versus regular attendance.
This guide provides simple, ready-to-use SQL queries for Planning Center Check-ins data. Each query is designed to answer common ministry questions without requiring deep SQL knowledge.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Check-ins module live in the `planning_center` schema. Always prefix table names with `planning_center.` when writing queries.
✅ CORRECT: `SELECT * FROM planning_center.checkins_check_ins`
❌ INCORRECT: `SELECT * FROM checkins_check_ins`
### Row Level Security (RLS)
Row Level Security automatically enforces:
* **tenant\_organization\_id** – results limited to your organization
* **system\_status** – only active records returned by default
**Skip manual filters for these columns**—RLS already applies them and redundant predicates can slow queries or mask data:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus your WHERE clauses on ministry-specific logic while trusting the database to keep tenant and status filters in place.
## Today's Attendance
### Who Checked In Today?
```sql theme={null}
-- List everyone who checked in today
SELECT
first_name,
last_name,
kind as check_in_type,
security_code,
created_at as checked_in_at,
CASE
WHEN checked_out_at IS NULL THEN 'Still here'
ELSE 'Checked out'
END as status
FROM planning_center.checkins_check_ins
WHERE DATE(created_at) = CURRENT_DATE
ORDER BY created_at DESC;
```
### Count Total Attendance by Type
```sql theme={null}
-- See how many regulars, guests, and volunteers checked in today
SELECT
kind as attendee_type,
COUNT(*) as total
FROM planning_center.checkins_check_ins
WHERE DATE(created_at) = CURRENT_DATE
GROUP BY kind
ORDER BY total DESC;
```
### Currently Checked In (Not Yet Checked Out)
```sql theme={null}
-- Find who is still checked in right now
SELECT
first_name,
last_name,
security_code,
kind as type,
created_at as checked_in_at,
EXTRACT(HOUR FROM AGE(NOW(), created_at)) as hours_checked_in
FROM planning_center.checkins_check_ins
WHERE DATE(created_at) = CURRENT_DATE
AND checked_out_at IS NULL
ORDER BY created_at;
```
## Location-Based Queries
### Check-ins by Location
```sql theme={null}
-- Count check-ins for each location today
SELECT
l.name as location_name,
COUNT(c.check_in_id) as total_checkins
FROM planning_center.checkins_locations l
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON l.location_id = cr.relationship_id
AND cr.relationship_type = 'Location'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND DATE(c.created_at) = CURRENT_DATE
GROUP BY l.name
HAVING COUNT(c.check_in_id) > 0
ORDER BY total_checkins DESC;
```
### Room Capacity Status
```sql theme={null}
-- Check how full each room is
SELECT
l.name as room,
l.max_occupancy as capacity,
COUNT(c.check_in_id) as current_count,
CASE
-- max_occupancy is 0 (not NULL) when no limit has been set
WHEN COALESCE(l.max_occupancy, 0) = 0 THEN 'No limit set'
WHEN COUNT(c.check_in_id) >= l.max_occupancy THEN 'FULL'
WHEN COUNT(c.check_in_id) >= l.max_occupancy * 0.8 THEN 'Nearly full'
ELSE 'Space available'
END as status
FROM planning_center.checkins_locations l
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON l.location_id = cr.relationship_id
AND cr.relationship_type = 'Location'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND DATE(c.created_at) = CURRENT_DATE
AND c.checked_out_at IS NULL -- Only currently present
WHERE l.kind = 'Location' -- Physical rooms only (Folder rows are groupings)
GROUP BY l.name, l.max_occupancy
ORDER BY l.name;
```
## Guest Tracking
### Guest Check-ins This Week
Two different columns describe guests, and they are not interchangeable:
* `kind = 'Guest'` — anyone checked in as a guest. This is the number you
usually want.
* `one_time_guest = true` — a narrower flag meaning the guest was checked in
**without creating a Planning Center person record**. Across production it
covers only about 29% of guest check-ins, so it undercounts badly if you
treat it as "first-time guests".
Neither column means "first visit ever". For genuine first visits, compare a
person's earliest check-in date — see
[First-Time Guest Return Rate](/planning-center/check-ins/advanced-queries).
```sql theme={null}
-- All guest check-ins from the past 7 days
SELECT
first_name,
last_name,
DATE(created_at) as visit_date,
security_code,
one_time_guest as checked_in_without_profile
FROM planning_center.checkins_check_ins
WHERE kind = 'Guest'
AND created_at >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY created_at DESC;
```
### Guest vs Regular Comparison
```sql theme={null}
-- Compare guest and regular attendance by day this month
SELECT
DATE(created_at) as date,
COUNT(CASE WHEN kind = 'Guest' THEN 1 END) as guests,
COUNT(CASE WHEN kind = 'Regular' THEN 1 END) as regulars,
COUNT(*) as total
FROM planning_center.checkins_check_ins
WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE)
GROUP BY DATE(created_at)
ORDER BY date DESC;
```
## Volunteer Management
### Today's Volunteers
```sql theme={null}
-- List all volunteers who checked in today
SELECT
first_name,
last_name,
created_at as checked_in_at,
CASE
WHEN checked_out_at IS NULL THEN 'Currently serving'
ELSE 'Finished serving'
END as status
FROM planning_center.checkins_check_ins
WHERE kind = 'Volunteer'
AND DATE(created_at) = CURRENT_DATE
ORDER BY created_at;
```
### Volunteer Coverage by Hour
```sql theme={null}
-- See volunteer coverage throughout the day
SELECT
DATE_TRUNC('hour', created_at) as hour,
COUNT(*) as volunteers_checked_in
FROM planning_center.checkins_check_ins
WHERE kind = 'Volunteer'
AND DATE(created_at) = CURRENT_DATE
GROUP BY DATE_TRUNC('hour', created_at)
ORDER BY hour;
```
## Weekly Patterns
### Attendance by Day of Week (Last 30 Days)
```sql theme={null}
-- See which days have the highest attendance
SELECT
TO_CHAR(created_at, 'FMDay') as day_of_week,
COUNT(*) as total_checkins,
COUNT(DISTINCT DATE(created_at)) as number_of_days,
ROUND(COUNT(*)::NUMERIC / COUNT(DISTINCT DATE(created_at))) as avg_per_day
FROM planning_center.checkins_check_ins
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY TO_CHAR(created_at, 'FMDay'), EXTRACT(DOW FROM created_at)
ORDER BY EXTRACT(DOW FROM created_at);
```
### Peak Check-in Times
```sql theme={null}
-- Find when most people check in
SELECT
TO_CHAR(created_at, 'HH:00 AM') as hour,
COUNT(*) as checkins
FROM planning_center.checkins_check_ins
WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY TO_CHAR(created_at, 'HH:00 AM'), EXTRACT(HOUR FROM created_at)
ORDER BY EXTRACT(HOUR FROM created_at);
```
## Event Analysis
### Recent Events with Attendance
```sql theme={null}
-- Show events and their attendance counts
SELECT
e.name as event_name,
COUNT(DISTINCT c.check_in_id) as total_attendance
FROM planning_center.checkins_events e
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON e.event_id = cr.relationship_id
AND cr.relationship_type = 'Event'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND c.created_at >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY e.name
HAVING COUNT(DISTINCT c.check_in_id) > 0
ORDER BY total_attendance DESC;
```
### Event Frequency Settings
```sql theme={null}
-- See how often events are scheduled
SELECT
name as event,
frequency,
CASE
WHEN archived_at IS NOT NULL THEN 'Archived'
ELSE 'Active'
END as status
FROM planning_center.checkins_events
ORDER BY name;
```
## Security and Safety
### Security Codes in Use Today
```sql theme={null}
-- List all security codes currently in use
SELECT DISTINCT
security_code,
COUNT(*) as times_used
FROM planning_center.checkins_check_ins
WHERE DATE(created_at) = CURRENT_DATE
AND checked_out_at IS NULL
AND security_code IS NOT NULL
GROUP BY security_code
ORDER BY security_code;
```
### Emergency Contact Information
```sql theme={null}
-- Find check-ins with emergency contacts
SELECT
first_name,
last_name,
emergency_contact_name,
emergency_contact_phone_number,
security_code,
created_at
FROM planning_center.checkins_check_ins
WHERE DATE(created_at) = CURRENT_DATE
AND emergency_contact_name IS NOT NULL
ORDER BY created_at DESC;
```
## Medical and Special Needs
### Check-ins with Medical Notes
```sql theme={null}
-- Find children with medical notes
SELECT
first_name,
last_name,
medical_notes,
security_code,
created_at as checked_in_at
FROM planning_center.checkins_check_ins
WHERE medical_notes IS NOT NULL
AND medical_notes != ''
AND DATE(created_at) = CURRENT_DATE
ORDER BY created_at DESC;
```
## Time-Based Analysis
### Average Check-in Duration
```sql theme={null}
-- Calculate how long people typically stay
SELECT
kind as attendee_type,
COUNT(*) as total_checkouts,
ROUND(AVG(EXTRACT(EPOCH FROM (checked_out_at - created_at))/3600), 1) as avg_hours
FROM planning_center.checkins_check_ins
WHERE checked_out_at IS NOT NULL
AND created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY kind
ORDER BY avg_hours DESC;
```
### Busiest Check-in Days (Last 3 Months)
```sql theme={null}
-- Find the days with the most check-ins
SELECT
DATE(created_at) as date,
TO_CHAR(created_at, 'FMDay') as day_name,
COUNT(*) as total_checkins
FROM planning_center.checkins_check_ins
WHERE created_at >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY DATE(created_at), TO_CHAR(created_at, 'FMDay')
ORDER BY total_checkins DESC
LIMIT 10;
```
## Tips for Using These Queries
1. **Dates**: Replace `CURRENT_DATE` with a specific date like `'2026-01-07'` (`'YYYY-MM-DD'`) to query historical data
2. **Limits**: Add `LIMIT 10` to any query to see just the first 10 results
3. **Sorting**: Change `DESC` to `ASC` if you want to reverse the sort order
4. **Filtering**: Add more `WHERE` conditions to narrow your results
## Next Steps
Ready for more complex analysis? Check out:
* [Advanced Queries](/planning-center/check-ins/advanced-queries) - Multi-table joins and complex calculations
* [Reporting Examples](/planning-center/check-ins/reporting-examples) - Complete reports ready for leadership
* [Data Model](/planning-center/check-ins/data-model) - Understand all available fields and relationships
# Planning Center Check-ins Data Model
Source: https://docs.getparable.io/planning-center/check-ins/data-model
Complete reference for Planning Center Check-ins tables in Parable: events, event times, locations, check-ins, and how attendance records connect.
This document provides complete documentation of the Planning Center Check-ins data model in Parable, including all tables, fields, and relationships.
## Overview
The Check-ins module contains **41 tables** supporting attendance tracking, child safety, volunteer management, and event organization.
## Visual Data Model
The diagram below shows the core entities and their relationships in the Check-ins module. Use it as a visual reference while exploring the detailed table definitions below.
### Core Entity Relationships
[Open diagram in new tab →](/diagrams/planning-center/check-ins-data-model-01.svg)
### Key Relationships Explained
**Event & Time Structure:**
* `EVENT`s represent recurring check-in programs (Sunday School, Nursery, etc.)
* `EVENT_TIME`s are specific time slots when events occur
* `EVENT_PERIOD`s define date ranges when events are active
* `LOCATION_EVENT_TIME` links locations to specific time slots
**Check-in Flow:**
1. Person arrives at a `LOCATION` during an `EVENT_TIME`
2. `CHECKIN` record created with security code
3. Labels printed based on `EVENT_LABEL` and `LOCATION_LABEL` configurations
4. `PERSON_EVENT` tracks long-term attendance patterns
**Label System:**
* `LABEL`s define printable tags (name tags, security labels, allergy alerts)
* `EVENT_LABEL` determines which labels print for an event
* `LOCATION_LABEL` determines which labels print at a location
* Multiple label types can apply to single check-in
**Security & Safety:**
* `PASS`es provide reusable check-in codes
* Security codes generated per check-in for child pickup
* `STATION`s configure check-in kiosks and admin workstations
* `THEME`s customize check-in interface appearance
**Headcount Tracking:**
* `HEADCOUNT` records aggregate attendance by type
* `ATTENDANCE_TYPE` categorizes attendees (kids, volunteers, guests)
* Tracked per `EVENT_TIME` for capacity planning
**Generic Relationship Pattern:**
* Check-in groups via `checkins_check_ins_relationships`
* Event associations via `checkins_events_relationships`
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Check-ins module are in the `planning_center` schema. You MUST prefix all table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.checkins_check_ins`
❌ INCORRECT: `SELECT * FROM checkins_check_ins`
### Row Level Security (RLS)
This database uses Row Level Security (RLS) to automatically filter data based on:
* **tenant\_organization\_id**: You only see data for your current organization
* **system\_status**: You only see 'active' records by default
**DO NOT add these filters to your WHERE clause** - they are applied automatically:
* ❌ `WHERE tenant_organization_id = 1` (unnecessary)
* ❌ `WHERE system_status = 'active'` (unnecessary)
The RLS policies ensure you only access data you're authorized to see. Adding these filters is redundant and can negatively impact query speed.
## Core Tables Overview
### Primary Entity Tables
* `checkins_check_ins` - Individual check-in records
* `checkins_people` - People who check in
* `checkins_events` - Event definitions (Sunday Service, Youth Group, etc.)
* `checkins_event_times` - Specific instances of events
* `checkins_event_periods` - Active check-in sessions
* `checkins_locations` - Physical and logical locations
* `checkins_stations` - Check-in kiosk stations
* `checkins_labels` - Print labels for check-ins
* `checkins_headcounts` - Manual attendance counts
* `checkins_check_in_times` - Specific check-in time records
### Supporting Entity Tables
* `checkins_attendance_types` - Types of attendance tracking
* `checkins_check_in_groups` - Groups of check-ins processed together
* `checkins_event_labels` - Labels associated with events
* `checkins_location_event_periods` - Location-specific period counts
* `checkins_location_event_times` - Location-specific time counts
* `checkins_location_labels` - Labels for locations
* `checkins_options` - Label printing options
* `checkins_passes` - Pass codes for check-ins
* `checkins_person_events` - Person-event connections
* `checkins_themes` - Visual themes for stations
* `checkins_organizations` - Organization settings
* `checkins_integration_links` - External system integrations
### Relationship Tables
* `checkins_check_ins_relationships` - Links check-ins to other entities
* `checkins_events_relationships` - Links events to related entities
* `checkins_event_times_relationships` - Links event times to events and locations
* `checkins_locations_relationships` - Links locations to parents and events
* `checkins_attendance_types_relationships` - Links attendance types to events
* `checkins_check_in_groups_relationships` - Links check-in groups to check-ins and stations
* `checkins_check_in_times_relationships` - Links check-in times to check-ins and locations
* `checkins_event_labels_relationships` - Links event labels to events and labels
* `checkins_event_periods_relationships` - Links event periods to events
* `checkins_headcounts_relationships` - Links headcounts to attendance types and event times
* `checkins_integration_links_relationships` - Links integration links to external entities
* `checkins_location_event_periods_relationships` - Links location event periods to locations and periods
* `checkins_location_event_times_relationships` - Links location event times to locations and event times
* `checkins_location_labels_relationships` - Links location labels to labels and locations
* `checkins_options_relationships` - Links options to labels
* `checkins_pass_relationships` - Links passes to people
* `checkins_person_events_relationships` - Links person events to people and events
* `checkins_people_relationships` - Links people to related entities
* `checkins_stations_relationships` - Links stations to themes and other entities
## Table Definitions
### checkins\_check\_ins
The main check-in record table that tracks individual check-ins.
| Column | Type | Description |
| -------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `check_in_id` | VARCHAR(64) | Planning Center check-in ID |
| `first_name` | VARCHAR(255) | First name of person checking in |
| `last_name` | VARCHAR(255) | Last name of person checking in |
| `kind` | VARCHAR(64) | Type: 'Regular', 'Guest', 'Volunteer' |
| `one_time_guest` | BOOLEAN | True when the guest was checked in without creating a Planning Center person record — **not** a first-visit flag |
| `security_code` | VARCHAR(64) | Security code for child pickup |
| `number` | INTEGER | Check-in number |
| `medical_notes` | TEXT | Medical information or allergies |
| `emergency_contact_name` | VARCHAR(255) | Emergency contact name |
| `emergency_contact_phone_number` | VARCHAR(255) | Emergency contact phone |
| `created_at` | TIMESTAMP | When check-in was created |
| `updated_at` | TIMESTAMP | Last update time |
| `confirmed_at` | TIMESTAMP | When check-in was confirmed |
| `checked_out_at` | TIMESTAMP | When person checked out |
| `confirmed` | BOOLEAN | Whether check-in is confirmed |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status: 'active', 'transferring', 'stale' |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
| `system_deleted_at` | TIMESTAMP WITH TIME ZONE | When record was soft-deleted |
Relationships (via `checkins_check_ins_relationships`):
* `relationship_type = 'Person'` → links to `checkins_people.person_id`
* `relationship_type = 'Event'` → links to `checkins_events.event_id`
* `relationship_type = 'EventTime'` → links to `checkins_event_times.event_time_id`
* `relationship_type = 'EventPeriod'` → links to `checkins_event_periods.event_period_id`
* `relationship_type = 'Location'` → links to `checkins_locations.location_id`
* `relationship_type = 'CheckedInAt'` → links to `checkins_stations.station_id`
* `relationship_type = 'CheckedInBy'` → links to `checkins_people.person_id`
* `relationship_type = 'CheckedOutBy'` → links to `checkins_people.person_id`
* `relationship_type = 'CheckInTime'` → links to `checkins_check_in_times.check_in_time_id`
### checkins\_people
People who have checked in to events.
| Column | Type | Description |
| ------------------------- | ------------------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_id` | VARCHAR(64) | Planning Center person ID |
| `avatar_url` | VARCHAR(2048) | Avatar image URL |
| `birthdate` | DATE | Date of birth |
| `check_in_count` | INTEGER | Total check-ins |
| `child` | BOOLEAN | True if child |
| `created_at` | TIMESTAMP | When person was created |
| `demographic_avatar_url` | VARCHAR(2048) | Demographic profile image URL |
| `first_name` | VARCHAR(255) | First name |
| `gender` | VARCHAR(64) | Gender |
| `grade` | INTEGER | School grade level |
| `headcounter` | BOOLEAN | Can do headcounts |
| `ignore_filters` | BOOLEAN | Ignores event filters |
| `last_checked_in_at` | TIMESTAMP | Last check-in time |
| `last_name` | VARCHAR(255) | Last name |
| `medical_notes` | TEXT | Medical information |
| `middle_name` | VARCHAR(255) | Middle name |
| `name` | VARCHAR(255) | Full name |
| `name_prefix` | VARCHAR(64) | Name prefix (Mr., Mrs., etc.) |
| `name_suffix` | VARCHAR(64) | Name suffix (Jr., Sr., etc.) |
| `passed_background_check` | BOOLEAN | Background check status |
| `permission` | VARCHAR(50) | Permission level |
| `top_permission` | VARCHAR(50) | Highest permission level |
| `updated_at` | TIMESTAMP | Last update time |
| `search_name` | VARCHAR(255) | Searchable name field |
| `addresses` | JSONB | Address records |
| `email_addresses` | JSONB | Email address records |
| `phone_numbers` | JSONB | Phone number records |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
| `system_deleted_at` | TIMESTAMP WITH TIME ZONE | When record was soft-deleted |
### checkins\_events
Recurring event definitions (e.g., "Sunday Service", "Youth Group").
| Column | Type | Description |
| ----------------------------- | ------------------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_id` | VARCHAR(64) | Planning Center event ID |
| `name` | VARCHAR(255) | Event name |
| `frequency` | VARCHAR(64) | How often event occurs |
| `enable_services_integration` | BOOLEAN | Links to Services module |
| `location_times_enabled` | BOOLEAN | Uses location-specific times |
| `pre_select_enabled` | BOOLEAN | Pre-selection allowed |
| `integration_key` | VARCHAR(64) | Integration identifier |
| `app_source` | VARCHAR(255) | Source application for the event |
| `archived_at` | TIMESTAMP | When event was archived |
| `created_at` | TIMESTAMP | When event was created |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
| `system_deleted_at` | TIMESTAMP WITH TIME ZONE | When record was soft-deleted |
### checkins\_event\_times
Specific instances of events with start and end times.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_time_id` | VARCHAR(64) | Planning Center event time ID |
| `created_at` | TIMESTAMP | When created |
| `day_of_week` | INTEGER | Day of week (0-6) |
| `guest_count` | INTEGER | Number of guests |
| `hides_at` | TIMESTAMP | When event time is hidden |
| `hour` | INTEGER | Hour of day (0-23) |
| `minute` | INTEGER | Minute of the hour |
| `name` | TEXT | Event time name |
| `regular_count` | INTEGER | Number of regular attendees |
| `shows_at` | TIMESTAMP | When event time is shown |
| `starts_at` | TIMESTAMP | Event start time |
| `total_count` | INTEGER | Total attendance count |
| `updated_at` | TIMESTAMP | Last update |
| `volunteer_count` | INTEGER | Number of volunteers |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_event_times_relationships`):
* `relationship_type = 'Event'` → links to `checkins_events.event_id`
### checkins\_event\_periods
Active check-in sessions for events.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_period_id` | VARCHAR(64) | Planning Center event period ID |
| `created_at` | TIMESTAMP | When created |
| `ends_at` | TIMESTAMP | Period end time |
| `guest_count` | INTEGER | Guest count |
| `note` | TEXT | Period notes |
| `regular_count` | INTEGER | Regular attendee count |
| `starts_at` | TIMESTAMP | Period start time |
| `volunteer_count` | INTEGER | Volunteer count |
| `quantity` | INTEGER | Total quantity |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_event_periods_relationships`):
* `relationship_type = 'Event'` → links to `checkins_events.event_id`
### checkins\_locations
Physical locations (rooms) and logical groupings.
| Column | Type | Description |
| ------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `location_id` | VARCHAR(64) | Planning Center location ID |
| `name` | VARCHAR(255) | Location name |
| `kind` | VARCHAR(255) | `'Location'` for a physical room, `'Folder'` for a grouping of locations |
| `opened` | BOOLEAN | Currently open for check-ins |
| `child_or_adult` | VARCHAR(50) | `'C'` (child) or `'A'` (adult); empty when unset |
| `gender` | VARCHAR(50) | `'M'` or `'F'` restriction; empty when unset |
| `age_min_in_months` | INTEGER | Minimum age in months |
| `age_max_in_months` | INTEGER | Maximum age in months |
| `age_on` | DATE | Date for age calculation |
| `age_range_by` | VARCHAR(255) | How age range is determined |
| `grade_min` | INTEGER | Minimum grade level |
| `grade_max` | INTEGER | Maximum grade level |
| `max_occupancy` | INTEGER | Room capacity; `0` when no limit has been set (never NULL) |
| `min_volunteers` | INTEGER | Minimum volunteers required; `0` when not configured (never NULL) |
| `attendees_per_volunteer` | INTEGER | Required attendees-per-volunteer ratio; `0` when not configured (never NULL) — guard divisions with `> 0` |
| `milestone` | VARCHAR(255) | Associated milestone |
| `position` | INTEGER | Sort order |
| `effective_date` | DATE | When settings take effect |
| `questions` | TEXT\[] | Custom questions for check-in |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
| `system_deleted_at` | TIMESTAMP WITH TIME ZONE | When record was soft-deleted |
Relationships (via `checkins_locations_relationships`):
* `relationship_type = 'Parent'` → links to parent `checkins_locations.location_id`
* `relationship_type = 'Event'` → links to `checkins_events.event_id`
### checkins\_stations
Check-in kiosk stations.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `station_id` | VARCHAR(64) | Planning Center station ID |
| `created_at` | TIMESTAMP | When created |
| `input_type` | TEXT | Input method |
| `input_type_options` | TEXT | Input configuration |
| `mode` | INTEGER | Station mode |
| `name` | TEXT | Station name |
| `timeout_seconds` | INTEGER | Session timeout in seconds |
| `check_in_count` | INTEGER | Number of check-ins at this station |
| `closes_at` | TIMESTAMP | When station closes |
| `next_shows_at` | TIMESTAMP | When station next becomes visible |
| `online` | BOOLEAN | Whether station is online |
| `open_for_check_in` | BOOLEAN | Whether station is accepting check-ins |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
### checkins\_labels
Print labels for check-ins.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `label_id` | VARCHAR(64) | Planning Center label ID |
| `created_at` | TIMESTAMP | When created |
| `name` | VARCHAR(255) | Label name |
| `prints_for` | VARCHAR(255) | Who gets this label |
| `roll` | VARCHAR(255) | Label roll type |
| `updated_at` | TIMESTAMP | Last update |
| `xml` | TEXT | Label XML definition |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
### checkins\_headcounts
Manual attendance counts.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `headcount_id` | VARCHAR(64) | Planning Center headcount ID |
| `total` | INTEGER | Total count |
| `updated_at` | TIMESTAMP | When count was updated |
| `created_at` | TIMESTAMP | When count was created |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_headcounts_relationships`):
* `relationship_type = 'AttendanceType'` → links to `checkins_attendance_types.attendance_type_id`
* `relationship_type = 'EventTime'` → links to `checkins_event_times.event_time_id`
* `relationship_type = 'Location'` → links to `checkins_locations.location_id`
### checkins\_check\_in\_times
Specific times when people checked into locations.
| Column | Type | Description |
| ------------------------ | ----------- | ---------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `check_in_time_id` | VARCHAR(64) | Planning Center check-in time ID |
| `kind` | VARCHAR(64) | Type of check-in time |
| `alerts` | JSONB | Alert data for this check-in time |
| `has_validated` | BOOLEAN | Whether check-in time has been validated |
| `services_integrated` | BOOLEAN | Whether linked to Services module |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_check_in_times_relationships`):
* `relationship_type = 'CheckIn'` → links to `checkins_check_ins.check_in_id`
* `relationship_type = 'EventTime'` → links to `checkins_event_times.event_time_id`
* `relationship_type = 'Location'` → links to `checkins_locations.location_id`
* `relationship_type = 'PreCheck'` → links to pre-check record
### checkins\_attendance\_types
Types of attendance tracking for events.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `attendance_type_id` | VARCHAR(64) | Planning Center attendance type ID |
| `color` | VARCHAR(255) | Display color |
| `created_at` | TIMESTAMP | When created |
| `attendance_limit` | INTEGER | Maximum attendance |
| `name` | VARCHAR(64) | Attendance type name |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_attendance_types_relationships`):
* `relationship_type = 'Event'` → links to `checkins_events.event_id`
### checkins\_check\_in\_groups
Groups of check-ins processed together for printing labels.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `check_in_group_id` | VARCHAR(64) | Planning Center check-in group ID |
| `check_ins_count` | INTEGER | Number of check-ins in group |
| `created_at` | TIMESTAMP | When group was created |
| `name_labels_count` | INTEGER | Number of name labels |
| `print_status` | VARCHAR(64) | Printing status |
| `security_labels_count` | INTEGER | Number of security labels |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_check_in_groups_relationships`):
* `relationship_type = 'CheckIn'` → links to `checkins_check_ins.check_in_id`
* `relationship_type = 'EventPeriod'` → links to `checkins_event_periods.event_period_id`
* `relationship_type = 'Station'` → links to `checkins_stations.station_id`
### checkins\_event\_labels
Labels associated with specific events.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_label_id` | VARCHAR(64) | Planning Center event label ID |
| `created_at` | TIMESTAMP | When created |
| `for_guest` | BOOLEAN | Print for guests |
| `for_regular` | BOOLEAN | Print for regular attendees |
| `for_volunteer` | BOOLEAN | Print for volunteers |
| `quantity` | INTEGER | Number of labels to print |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_event_labels_relationships`):
* `relationship_type = 'Event'` → links to `checkins_events.event_id`
* `relationship_type = 'Label'` → links to `checkins_labels.label_id`
### checkins\_location\_event\_periods
Location-specific attendance counts for event periods.
| Column | Type | Description |
| -------------------------- | ----------- | ---------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `location_event_period_id` | VARCHAR(64) | Planning Center location event period ID |
| `created_at` | TIMESTAMP | When created |
| `guest_count` | INTEGER | Guest count for this location |
| `regular_count` | INTEGER | Regular attendee count for this location |
| `updated_at` | TIMESTAMP | Last update |
| `volunteer_count` | INTEGER | Volunteer count for this location |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_location_event_periods_relationships`):
* `relationship_type = 'EventPeriod'` → links to `checkins_event_periods.event_period_id`
* `relationship_type = 'Location'` → links to `checkins_locations.location_id`
### checkins\_location\_event\_times
Location-specific attendance counts for event times.
| Column | Type | Description |
| ------------------------ | ----------- | ---------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `location_event_time_id` | VARCHAR(64) | Planning Center location event time ID |
| `created_at` | TIMESTAMP | When created |
| `guest_count` | INTEGER | Guest count for this location |
| `regular_count` | INTEGER | Regular attendee count for this location |
| `updated_at` | TIMESTAMP | Last update |
| `volunteer_count` | INTEGER | Volunteer count for this location |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_location_event_times_relationships`):
* `relationship_type = 'EventTime'` → links to `checkins_event_times.event_time_id`
* `relationship_type = 'Location'` → links to `checkins_locations.location_id`
### checkins\_location\_labels
Labels associated with specific locations.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `location_label_id` | VARCHAR(64) | Planning Center location label ID |
| `created_at` | TIMESTAMP | When created |
| `for_guest` | BOOLEAN | Print for guests |
| `for_regular` | BOOLEAN | Print for regular attendees |
| `for_volunteer` | BOOLEAN | Print for volunteers |
| `quantity` | INTEGER | Number of labels to print |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_location_labels_relationships`):
* `relationship_type = 'Label'` → links to `checkins_labels.label_id`
* `relationship_type = 'Location'` → links to `checkins_locations.location_id`
### checkins\_options
Label printing options and configurations.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `option_id` | VARCHAR(64) | Planning Center option ID |
| `body` | TEXT | Option configuration body |
| `created_at` | TIMESTAMP | When created |
| `quantity` | INTEGER | Quantity setting |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_options_relationships`):
* `relationship_type = 'Label'` → links to `checkins_labels.label_id`
### checkins\_passes
Pass codes for secure check-ins.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `pass_id` | VARCHAR(64) | Planning Center pass ID |
| `code` | VARCHAR(255) | Pass code |
| `created_at` | TIMESTAMP | When created |
| `kind` | VARCHAR(255) | Type of pass |
| `send_to` | VARCHAR(255) | Delivery destination for pass |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_pass_relationships`):
* `relationship_type = 'Person'` → links to `checkins_people.person_id`
### checkins\_person\_events
Connections between people and events they've attended.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_event_id` | VARCHAR(64) | Planning Center person event ID |
| `check_in_count` | INTEGER | Number of check-ins for this event |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
Relationships (via `checkins_person_events_relationships`):
* `relationship_type = 'Event'` → links to `checkins_events.event_id`
* `relationship_type = 'Person'` → links to `checkins_people.person_id`
* `relationship_type = 'FirstCheckIn'` → links to `checkins_check_ins.check_in_id`
* `relationship_type = 'LastCheckIn'` → links to `checkins_check_ins.check_in_id`
### checkins\_themes
Visual themes for check-in stations.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `theme_id` | VARCHAR(64) | Planning Center theme ID |
| `background_color` | TEXT | Background color |
| `color` | TEXT | Primary color |
| `created_at` | TIMESTAMP | When created |
| `image_thumbnail` | TEXT | Thumbnail image URL |
| `mode` | TEXT | Theme mode |
| `name` | TEXT | Theme name |
| `text_color` | TEXT | Text color |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
### checkins\_organizations
Organization settings and configuration.
| Column | Type | Description |
| ------------------------ | ------------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `organization_id` | VARCHAR(64) | Planning Center organization ID |
| `avatar_url` | VARCHAR(2048) | Organization avatar URL |
| `created_at` | TIMESTAMP | When created |
| `daily_check_ins` | INTEGER | Daily check-in count |
| `date_format_pattern` | VARCHAR(64) | Date format pattern |
| `name` | VARCHAR(255) | Organization name |
| `time_zone` | VARCHAR(64) | Organization time zone |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
### checkins\_integration\_links
Links to external systems and integrations.
| Column | Type | Description |
| ------------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `integration_link_id` | VARCHAR(64) | Planning Center integration link ID |
| `remote_app` | TEXT | Remote application name |
| `remote_gid` | TEXT | Remote global ID |
| `remote_id` | TEXT | Remote ID |
| `remote_type` | TEXT | Remote type |
| `sync_future_assignment_types` | BOOLEAN | Sync future assignments |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration source identifier |
## Relationship Tables
All relationship tables share this common structure, with the entity ID column named after the parent entity:
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `_id` | VARCHAR(64) | Parent entity ID (e.g., `check_in_id`, `event_id`) |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_integration_id` | INTEGER | Integration source identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
### checkins\_check\_ins\_relationships
Links check-ins to people, events, locations, and other entities.
Parent ID column: `check_in_id`
Common relationship types:
* `Person` - Links to checkins\_people
* `Event` - Links to checkins\_events
* `EventTime` - Links to checkins\_event\_times
* `EventPeriod` - Links to checkins\_event\_periods
* `Location` - Links to checkins\_locations
### checkins\_events\_relationships
Links events to other entities.
Parent ID column: `event_id`
### checkins\_locations\_relationships
Links locations to parent locations and events.
Parent ID column: `location_id`
Common relationship types:
* `Parent` - Links to parent checkins\_locations
* `Event` - Links to checkins\_events
### checkins\_event\_times\_relationships
Links event times to events and locations.
Parent ID column: `event_time_id`
### checkins\_attendance\_types\_relationships
Links attendance types to events and other entities.
Parent ID column: `attendance_type_id`
### checkins\_check\_in\_groups\_relationships
Links check-in groups to check-ins, event periods, and stations.
Parent ID column: `checkin_group_id`
### checkins\_check\_in\_times\_relationships
Links check-in times to check-ins, event times, and locations.
Parent ID column: `check_in_time_id`
### checkins\_event\_labels\_relationships
Links event labels to events and labels.
Parent ID column: `event_label_id`
### checkins\_event\_periods\_relationships
Links event periods to events.
Parent ID column: `event_period_id`
### checkins\_headcounts\_relationships
Links headcounts to attendance types, event times, and locations.
Parent ID column: `headcount_id`
### checkins\_integration\_links\_relationships
Links integration links to external entities.
Parent ID column: `integration_link_id`
### checkins\_location\_event\_periods\_relationships
Links location event periods to locations and event periods.
Parent ID column: `location_event_period_id`
### checkins\_location\_event\_times\_relationships
Links location event times to locations and event times.
Parent ID column: `location_event_time_id`
### checkins\_location\_labels\_relationships
Links location labels to labels and locations.
Parent ID column: `location_label_id`
### checkins\_options\_relationships
Links options to labels.
Parent ID column: `option_id`
### checkins\_pass\_relationships
Links passes to people.
Parent ID column: `pass_id`
### checkins\_person\_events\_relationships
Links person events to people, events, and check-ins.
Parent ID column: `person_event_id`
### checkins\_people\_relationships
Links people to related entities.
Parent ID column: `person_id`
### checkins\_stations\_relationships
Links stations to themes and other entities.
Parent ID column: `station_id`
## System Fields
All tables include these system fields for data management:
* `tenant_organization_id` - Multi-tenant organization identifier
* `system_status` - Data lifecycle status:
* `transferring` - Being imported from Planning Center
* `active` - Current active data
* `stale` - Marked for removal
* `system_created_at` - When record was created in Parable
* `system_updated_at` - When record was last updated in Parable
## Common Query Patterns
### Joining Check-ins to People
```sql theme={null}
SELECT
c.*,
p.first_name,
p.last_name,
p.birthdate
FROM planning_center.checkins_check_ins c
JOIN planning_center.checkins_check_ins_relationships cr
ON c.check_in_id = cr.check_in_id
AND cr.relationship_type = 'Person'
JOIN planning_center.checkins_people p
ON cr.relationship_id = p.person_id;
```
### Joining Check-ins to Locations
```sql theme={null}
SELECT
c.*,
l.name as location_name,
l.max_occupancy
FROM planning_center.checkins_check_ins c
JOIN planning_center.checkins_check_ins_relationships cr
ON c.check_in_id = cr.check_in_id
AND cr.relationship_type = 'Location'
JOIN planning_center.checkins_locations l
ON cr.relationship_id = l.location_id;
```
### Finding Location Hierarchy
Note: `checkins_locations` has no `parent_id` column. Parent relationships are
stored in `checkins_locations_relationships` with `relationship_type = 'Parent'`.
```sql theme={null}
WITH RECURSIVE location_tree AS (
-- Top-level locations: those with no Parent relationship entry
SELECT
l.location_id,
l.name,
NULL::varchar AS parent_location_id,
0 AS level
FROM planning_center.checkins_locations l
WHERE NOT EXISTS (
SELECT 1
FROM planning_center.checkins_locations_relationships lr
WHERE lr.location_id = l.location_id
AND lr.relationship_type = 'Parent'
)
UNION ALL
SELECT
l.location_id,
l.name,
lt.location_id AS parent_location_id,
lt.level + 1
FROM planning_center.checkins_locations l
JOIN planning_center.checkins_locations_relationships lr
ON l.location_id = lr.location_id
AND lr.relationship_type = 'Parent'
JOIN location_tree lt ON lr.relationship_id = lt.location_id
)
SELECT * FROM location_tree;
```
## Data Integrity Rules
1. **Schema Qualification**: Always use `planning_center.` prefix for all table references
2. **Row Level Security**: RLS automatically handles multi-tenancy and status filtering - do not add manual filters
3. **Monetary Values**: Any fee or purchase columns are stored in cents - divide by 100.0 for display
4. **Check-in Status Flags**: Use fields like `confirmed_at` and `checked_out_at` on `checkins_check_ins` to determine check-in state
5. **Relationship Tables**: Core entity tables (like `checkins_check_ins`, `checkins_event_times`, `checkins_locations`) do **not** have direct FK columns. Use the corresponding `*_relationships` table to navigate associations (e.g., `checkins_check_ins_relationships`, `checkins_event_times_relationships`, `checkins_locations_relationships`)
## Common Mistakes to Avoid
1. **Missing Schema Prefix**
* ❌ `FROM checkins_check_ins`
* ✅ `FROM planning_center.checkins_check_ins`
2. **Adding Redundant RLS Filters**
* ❌ `WHERE tenant_organization_id = 1 AND system_status = 'active'`
* ✅ Trust RLS to handle this automatically
3. **Joining Without Schema**
* ❌ `JOIN checkins_locations l ON ...`
* ✅ `JOIN planning_center.checkins_locations l ON ...`
4. **Skipping Currency Conversion**
* ❌ `SELECT fee_cents as fee`
* ✅ `SELECT fee_cents / 100.0 as fee`
## Performance Considerations
1. **Indexes**: All tables have optimized indexes on:
* Primary keys and entity IDs
* Join columns and foreign keys
* Date columns for time-based queries
2. **Query Optimization**:
* Always use the `planning_center.` schema prefix
* RLS handles tenant and status filtering automatically
* Filter by event period or location status when relevant
* Consider using CTEs for complex hierarchical queries
* Join through the `*_relationships` tables — entity tables carry no foreign-key columns
## Next Steps
* Return to [Basic Queries](/planning-center/check-ins/basic-queries) for simple examples
* Review [Advanced Queries](/planning-center/check-ins/advanced-queries) for complex analysis
# Planning Center Check-ins SQL Queries
Source: https://docs.getparable.io/planning-center/check-ins/overview
Query Planning Center Check-ins data with SQL to analyze weekend attendance, track first-time guests, and understand service-by-service patterns.
## Transform Your Check-in Data Into Ministry Insights
Your check-in system is the heartbeat of your weekend services and events. With Parable's SQL access to Planning Center Check-ins data, you can analyze attendance patterns, optimize volunteer placement, and ensure child safety—all with nightly-synchronized data at your fingertips.
## Quick Start
Ready to see who checked in today? Here's your first query:
```sql theme={null}
-- See the 10 most recent check-ins
SELECT
c.check_in_id,
c.first_name,
c.last_name,
c.kind, -- 'Regular', 'Guest', 'Volunteer'
c.security_code,
c.created_at as checked_in_at,
c.checked_out_at
FROM planning_center.checkins_check_ins c
WHERE c.created_at >= CURRENT_DATE
ORDER BY c.created_at DESC
LIMIT 10;
```
## What You Can Do With Check-ins Queries
### 👥 Track Attendance Patterns
* Monitor weekly service attendance trends
* Compare attendance across different service times
* Identify seasonal patterns in attendance
* Track first-time guest retention
### 👶 Manage Children's Ministry
* Monitor classroom capacity and ratios
* Track volunteer-to-child ratios with current data
* Analyze age group distributions
* Generate security reports for child safety
### 🙋 Optimize Volunteer Placement
* Identify understaffed locations
* Track volunteer attendance and reliability
* Balance volunteer assignments across services
* Monitor volunteer check-in patterns
### 📊 Generate Leadership Reports
* Create attendance dashboards for leadership
* Track growth metrics across campuses
* Monitor event effectiveness
* Export data for strategic planning
## Available Tables
Your Planning Center Check-ins data is organized into these main tables:
| Table | What It Contains | Key Use Cases |
| ------------------------- | ----------------------------------- | -------------------------------------- |
| `checkins_check_ins` | Individual check-in records | Attendance tracking, security codes |
| `checkins_people` | People who check in | Person profiles, contact info |
| `checkins_events` | Event definitions | Service times, recurring events |
| `checkins_event_times` | Specific times for events | Service schedules, time-based analysis |
| `checkins_locations` | Physical or logical locations | Rooms, classrooms, volunteer areas |
| `checkins_event_periods` | Check-in sessions | Service-specific attendance |
| `checkins_headcounts` | Manual attendance counts | Total attendance tracking |
| `checkins_check_in_times` | Times people checked into locations | Location-specific attendance |
| `checkins_labels` | Print labels for check-ins | Name tags, security labels |
| `checkins_stations` | Check-in kiosk stations | Station performance, usage patterns |
## Understanding Relationships
Just like with other Planning Center data, Check-ins stores relationships in separate tables to maintain data integrity:
* `checkins_check_ins_relationships` - Links check-ins to people, events, locations
* `checkins_events_relationships` - Links events to related entities
* `checkins_locations_relationships` - Links locations to events and parent locations
* `checkins_event_times_relationships` - Links event times to events and locations
We'll show you exactly how to join these tables in our examples!
## Common Check-ins Scenarios
### Finding Today's Check-ins by Location
```sql theme={null}
-- Get check-ins grouped by location for today
SELECT
l.name as location_name,
l.kind as location_type,
COUNT(DISTINCT c.check_in_id) as total_checkins,
COUNT(DISTINCT CASE WHEN c.kind = 'Regular' THEN c.check_in_id END) as regular_checkins,
COUNT(DISTINCT CASE WHEN c.kind = 'Guest' THEN c.check_in_id END) as guest_checkins,
COUNT(DISTINCT CASE WHEN c.kind = 'Volunteer' THEN c.check_in_id END) as volunteer_checkins
FROM planning_center.checkins_check_ins c
JOIN planning_center.checkins_check_ins_relationships cr
ON c.check_in_id = cr.check_in_id
AND cr.relationship_type = 'Location'
JOIN planning_center.checkins_locations l
ON cr.relationship_id = l.location_id
WHERE DATE(c.created_at) = CURRENT_DATE
GROUP BY l.name, l.kind
ORDER BY total_checkins DESC;
```
### Tracking Volunteer Coverage
```sql theme={null}
-- Check volunteer-to-child ratios by location
SELECT
l.name as location,
l.attendees_per_volunteer as required_ratio,
COUNT(DISTINCT CASE WHEN c.kind = 'Regular' THEN c.check_in_id END) as children,
COUNT(DISTINCT CASE WHEN c.kind = 'Volunteer' THEN c.check_in_id END) as volunteers,
CASE
WHEN COUNT(DISTINCT CASE WHEN c.kind = 'Volunteer' THEN c.check_in_id END) > 0
THEN ROUND(COUNT(DISTINCT CASE WHEN c.kind = 'Regular' THEN c.check_in_id END)::NUMERIC /
COUNT(DISTINCT CASE WHEN c.kind = 'Volunteer' THEN c.check_in_id END), 1)
ELSE NULL
END as actual_ratio
FROM planning_center.checkins_locations l
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON l.location_id = cr.relationship_id
AND cr.relationship_type = 'Location'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND DATE(c.created_at) = CURRENT_DATE
AND c.checked_out_at IS NULL -- Only currently checked in
WHERE l.kind = 'Location' -- Physical rooms only (Folder rows are groupings)
AND l.child_or_adult = 'C'
GROUP BY l.name, l.attendees_per_volunteer
ORDER BY l.name;
```
## Next Steps
📚 **New to SQL?** Start with [Basic Queries](/planning-center/check-ins/basic-queries) for simple, powerful queries you can use today.
🚀 **Ready for More?** Check out [Advanced Queries](/planning-center/check-ins/advanced-queries) for complex analysis and reporting.
📊 **Need Reports?** See [Reporting Examples](/planning-center/check-ins/reporting-examples) for complete, production-ready reports.
🔍 **Want Details?** Review the [Data Model](/planning-center/check-ins/data-model) for complete table documentation.
## Common Questions
### What's the difference between an Event and an Event Time?
* An **Event** is the recurring definition (e.g., "Sunday Service")
* An **Event Time** is a specific instance (e.g., "Sunday Service at 9:00 AM on Jan 7, 2024")
* Event Periods represent active check-in sessions
### How do I find people who haven't checked out?
Look for records where `checked_out_at IS NULL`:
```sql theme={null}
SELECT first_name, last_name, security_code, created_at
FROM planning_center.checkins_check_ins
WHERE checked_out_at IS NULL
AND DATE(created_at) = CURRENT_DATE
ORDER BY created_at DESC;
```
### What does the 'kind' field mean?
The `kind` field identifies the type of check-in:
* `Regular` - Standard attendee (usually children)
* `Guest` - First-time or visiting attendee
* `Volunteer` - Someone serving in ministry
### How do I track guests?
Use `kind = 'Guest'`. The separate `one_time_guest` flag means the guest was
checked in *without creating a person record*, and covers only about 29% of
guest check-ins — it is not a first-visit flag.
```sql theme={null}
SELECT
COUNT(*) as guest_check_ins,
COUNT(*) FILTER (WHERE one_time_guest = true) as without_a_profile
FROM planning_center.checkins_check_ins
WHERE kind = 'Guest'
AND DATE(created_at) >= CURRENT_DATE - INTERVAL '7 days';
```
For genuine first visits, compare each person's earliest check-in date — see
[First-Time Guest Return Rate](/planning-center/check-ins/advanced-queries).
## Getting Help
* 🐛 Found an issue? Report it at [github.com/getparable/parable-api/issues](https://github.com/getparable/parable-api/issues)
* 📖 Need more examples? Check our other query guides in this folder
* 💬 Have questions? Reach out to your Parable support team
***
*Your attendance data tells a story of growth and engagement. Let's help you understand it.*
# Planning Center Check-ins Report Examples
Source: https://docs.getparable.io/planning-center/check-ins/reporting-examples
Production-ready Check-ins reports for church leadership: weekly attendance trends, guest follow-up, and volunteer management, ready to schedule.
This guide provides complete, production-ready SQL reports for Planning Center Check-ins data. These reports are designed to be run regularly for leadership meetings, board reports, and ministry planning.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Check-ins module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your reports.
✅ CORRECT: `SELECT * FROM planning_center.checkins_check_ins`
❌ INCORRECT: `SELECT * FROM checkins_check_ins`
### Row Level Security (RLS)
Row Level Security automatically enforces:
* **tenant\_organization\_id** – results limited to your organization
* **system\_status** – active records returned by default
**Skip manual filters for these columns**—RLS already applies them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Keep your WHERE clauses focused on ministry-specific timeframes, attendance types, and volunteer metrics while trusting RLS for tenancy and status.
## Executive Dashboard Report
### Weekly Executive Summary
```sql theme={null}
-- Executive summary report for leadership meetings
WITH current_week AS (
SELECT
COUNT(DISTINCT check_in_id) as total_checkins,
COUNT(DISTINCT CASE WHEN kind = 'Regular' THEN check_in_id END) as regular_attendees,
COUNT(DISTINCT CASE WHEN kind = 'Guest' THEN check_in_id END) as guests,
COUNT(DISTINCT CASE WHEN kind = 'Volunteer' THEN check_in_id END) as volunteers,
COUNT(DISTINCT CASE WHEN one_time_guest = true THEN check_in_id END) as guests_without_profile
FROM planning_center.checkins_check_ins
WHERE created_at >= DATE_TRUNC('week', CURRENT_DATE)
AND created_at < DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '1 week'
),
previous_week AS (
SELECT
COUNT(DISTINCT check_in_id) as total_checkins,
COUNT(DISTINCT CASE WHEN kind = 'Regular' THEN check_in_id END) as regular_attendees,
COUNT(DISTINCT CASE WHEN kind = 'Guest' THEN check_in_id END) as guests
FROM planning_center.checkins_check_ins
WHERE created_at >= DATE_TRUNC('week', CURRENT_DATE) - INTERVAL '1 week'
AND created_at < DATE_TRUNC('week', CURRENT_DATE)
),
four_week_avg AS (
SELECT
AVG(weekly_count) as avg_attendance
FROM (
SELECT
DATE_TRUNC('week', created_at) as week,
COUNT(DISTINCT check_in_id) as weekly_count
FROM planning_center.checkins_check_ins
WHERE created_at >= DATE_TRUNC('week', CURRENT_DATE) - INTERVAL '4 weeks'
AND created_at < DATE_TRUNC('week', CURRENT_DATE)
GROUP BY DATE_TRUNC('week', created_at)
) w
)
SELECT
'=== WEEKLY EXECUTIVE SUMMARY ===' as report_header,
TO_CHAR(DATE_TRUNC('week', CURRENT_DATE), 'FMMonth DD, YYYY') as week_beginning,
'' as blank1,
'--- ATTENDANCE METRICS ---' as section1,
cw.total_checkins as total_attendance_this_week,
pw.total_checkins as total_attendance_last_week,
cw.total_checkins - pw.total_checkins as week_over_week_change,
ROUND(((cw.total_checkins - pw.total_checkins)::NUMERIC / NULLIF(pw.total_checkins, 0)) * 100, 1) as percent_change,
ROUND(fwa.avg_attendance, 0) as four_week_average,
'' as blank2,
'--- ATTENDANCE BREAKDOWN ---' as section2,
cw.regular_attendees as regular_attendees,
cw.guests as total_guests,
cw.guests_without_profile as guests_without_profile,
cw.volunteers as volunteers_serving,
ROUND((cw.guests::NUMERIC / NULLIF(cw.total_checkins, 0)) * 100, 1) as guest_percentage,
'' as blank3,
'--- COMPARISON TO LAST WEEK ---' as section3,
cw.regular_attendees - pw.regular_attendees as regular_change,
cw.guests - pw.guests as guest_change
FROM current_week cw, previous_week pw, four_week_avg fwa;
```
### Monthly Attendance Trend Report
```sql theme={null}
-- Monthly attendance trend report with year-over-year comparison
WITH monthly_data AS (
SELECT
DATE_TRUNC('month', created_at) as month,
COUNT(DISTINCT check_in_id) as total_attendance,
COUNT(DISTINCT CASE WHEN kind = 'Regular' THEN check_in_id END) as regular_attendance,
COUNT(DISTINCT CASE WHEN kind = 'Guest' THEN check_in_id END) as guest_attendance,
COUNT(DISTINCT CASE WHEN kind = 'Volunteer' THEN check_in_id END) as volunteer_attendance,
COUNT(DISTINCT DATE(created_at)) as service_days
FROM planning_center.checkins_check_ins
WHERE created_at >= DATE_TRUNC('year', CURRENT_DATE) - INTERVAL '1 year'
GROUP BY DATE_TRUNC('month', created_at)
),
monthly_comparison AS (
SELECT
month,
TO_CHAR(month, 'YYYY-MM') as month_year,
TO_CHAR(month, 'FMMonth') as month_name,
total_attendance,
regular_attendance,
guest_attendance,
volunteer_attendance,
service_days,
ROUND(total_attendance::NUMERIC / NULLIF(service_days, 0), 0) as avg_per_service,
LAG(total_attendance, 12) OVER (ORDER BY month) as same_month_last_year,
LAG(total_attendance, 1) OVER (ORDER BY month) as previous_month
FROM monthly_data
)
SELECT
month_name,
month_year,
total_attendance,
regular_attendance,
guest_attendance,
volunteer_attendance,
avg_per_service,
CASE
WHEN same_month_last_year IS NOT NULL THEN
total_attendance - same_month_last_year
ELSE NULL
END as yoy_change,
CASE
WHEN same_month_last_year IS NOT NULL AND same_month_last_year > 0 THEN
ROUND(((total_attendance - same_month_last_year)::NUMERIC / same_month_last_year) * 100, 1)
ELSE NULL
END as yoy_percent_change,
CASE
WHEN previous_month IS NOT NULL THEN
total_attendance - previous_month
ELSE NULL
END as month_over_month_change
FROM monthly_comparison
WHERE month >= DATE_TRUNC('year', CURRENT_DATE)
ORDER BY month_year DESC;
```
## Children's Ministry Reports
### Children's Ministry Weekly Report
```sql theme={null}
-- Comprehensive children's ministry report with safety metrics
WITH children_locations AS (
SELECT
location_id,
name,
age_min_in_months,
age_max_in_months,
max_occupancy,
attendees_per_volunteer,
min_volunteers
FROM planning_center.checkins_locations
WHERE child_or_adult = 'C'
AND kind = 'Location'
),
current_week_stats AS (
SELECT
cl.name as classroom,
cl.age_min_in_months,
cl.age_max_in_months,
cl.max_occupancy,
cl.attendees_per_volunteer as required_ratio,
cl.min_volunteers,
COUNT(DISTINCT CASE WHEN c.kind = 'Regular' THEN c.check_in_id END) as total_children,
COUNT(DISTINCT CASE WHEN c.kind = 'Guest' THEN c.check_in_id END) as guest_children,
COUNT(DISTINCT CASE WHEN c.kind = 'Volunteer' THEN c.check_in_id END) as volunteers,
COUNT(DISTINCT c.security_code) as unique_security_codes,
COUNT(DISTINCT DATE(c.created_at)) as days_with_checkins
FROM children_locations cl
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON cl.location_id = cr.relationship_id
AND cr.relationship_type = 'Location'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND c.created_at >= DATE_TRUNC('week', CURRENT_DATE)
GROUP BY cl.name, cl.age_min_in_months, cl.age_max_in_months,
cl.max_occupancy, cl.attendees_per_volunteer, cl.min_volunteers
)
SELECT
classroom,
CASE
WHEN age_min_in_months IS NOT NULL AND age_max_in_months IS NOT NULL THEN
(age_min_in_months / 12)::TEXT || '-' || (age_max_in_months / 12)::TEXT || ' years'
ELSE 'All ages'
END as age_range,
total_children,
guest_children,
volunteers,
CASE
WHEN volunteers > 0 THEN ROUND(total_children::NUMERIC / volunteers, 1)
ELSE NULL
END as actual_ratio,
required_ratio,
CASE
-- 0 means "no limit set", so treat it the same as a missing value
WHEN COALESCE(max_occupancy, 0) > 0 THEN max_occupancy::TEXT
ELSE 'No limit'
END as room_capacity,
CASE
WHEN COALESCE(max_occupancy, 0) > 0 AND total_children > 0 THEN
ROUND((total_children::NUMERIC / max_occupancy) * 100, 0)::TEXT || '%'
ELSE '-'
END as capacity_used,
CASE
WHEN COALESCE(min_volunteers, 0) > 0 AND volunteers < min_volunteers THEN '⚠️ Under minimum volunteers'
WHEN COALESCE(required_ratio, 0) > 0 AND volunteers > 0
AND (total_children::NUMERIC / volunteers) > required_ratio THEN '⚠️ Ratio exceeded'
WHEN COALESCE(max_occupancy, 0) > 0 AND total_children >= max_occupancy THEN '⚠️ At capacity'
WHEN total_children = 0 THEN 'No attendance'
ELSE '✓ OK'
END as status
FROM current_week_stats
ORDER BY
CASE
WHEN COALESCE(min_volunteers, 0) > 0 AND volunteers < min_volunteers AND total_children > 0 THEN 1
WHEN COALESCE(required_ratio, 0) > 0 AND volunteers > 0
AND (total_children::NUMERIC / volunteers) > required_ratio THEN 2
ELSE 3
END,
age_min_in_months NULLS LAST,
classroom;
```
### Age Distribution Analysis Report
```sql theme={null}
-- Analyze age distribution for curriculum and volunteer planning
WITH age_groups AS (
SELECT
l.name as location_name,
l.age_min_in_months,
l.age_max_in_months,
CASE
WHEN l.age_max_in_months <= 24 THEN 'Nursery (0-2)'
WHEN l.age_max_in_months <= 60 THEN 'Preschool (2-5)'
WHEN l.age_max_in_months <= 144 THEN 'Elementary (5-12)'
WHEN l.age_max_in_months <= 216 THEN 'Youth (12-18)'
ELSE 'Adult'
END as age_category,
COUNT(DISTINCT c.check_in_id) as total_checkins,
COUNT(DISTINCT CASE WHEN c.kind = 'Regular' THEN c.check_in_id END) as regular_checkins,
COUNT(DISTINCT CASE WHEN c.kind = 'Guest' THEN c.check_in_id END) as guest_checkins
FROM planning_center.checkins_locations l
LEFT JOIN planning_center.checkins_check_ins_relationships cr
ON l.location_id = cr.relationship_id
AND cr.relationship_type = 'Location'
LEFT JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
AND c.created_at >= DATE_TRUNC('month', CURRENT_DATE)
WHERE l.child_or_adult = 'C'
AND l.kind = 'Location'
GROUP BY l.name, l.age_min_in_months, l.age_max_in_months
),
age_summary AS (
SELECT
age_category,
SUM(total_checkins) as total,
SUM(regular_checkins) as regulars,
SUM(guest_checkins) as guests,
COUNT(DISTINCT location_name) as num_locations
FROM age_groups
GROUP BY age_category
)
SELECT
age_category,
total as total_attendance,
regulars as regular_children,
guests as guest_children,
num_locations as classrooms,
ROUND(total::NUMERIC / NULLIF(num_locations, 0), 0) as avg_per_classroom,
ROUND((total::NUMERIC / NULLIF((SELECT SUM(total) FROM age_summary), 0)) * 100, 1) as percent_of_total
FROM age_summary
WHERE total > 0
ORDER BY
CASE age_category
WHEN 'Nursery (0-2)' THEN 1
WHEN 'Preschool (2-5)' THEN 2
WHEN 'Elementary (5-12)' THEN 3
WHEN 'Youth (12-18)' THEN 4
ELSE 5
END;
```
## Volunteer Management Reports
### Volunteer Service Report
```sql theme={null}
-- Comprehensive volunteer service tracking report
WITH volunteer_service AS (
SELECT
p.person_id,
p.first_name,
p.last_name,
COUNT(DISTINCT DATE(c.created_at)) as days_served,
COUNT(DISTINCT DATE_TRUNC('week', c.created_at)) as weeks_served,
MIN(c.created_at) as first_service,
MAX(c.created_at) as last_service,
COUNT(DISTINCT l.location_id) as locations_served,
STRING_AGG(DISTINCT l.name, ', ' ORDER BY l.name) as service_areas
FROM planning_center.checkins_people p
JOIN planning_center.checkins_check_ins_relationships cr_person
ON p.person_id = cr_person.relationship_id
AND cr_person.relationship_type = 'Person'
JOIN planning_center.checkins_check_ins c
ON cr_person.check_in_id = c.check_in_id
AND c.kind = 'Volunteer'
AND c.created_at >= DATE_TRUNC('quarter', CURRENT_DATE)
LEFT JOIN planning_center.checkins_check_ins_relationships cr_location
ON c.check_in_id = cr_location.check_in_id
AND cr_location.relationship_type = 'Location'
LEFT JOIN planning_center.checkins_locations l
ON cr_location.relationship_id = l.location_id
GROUP BY p.person_id, p.first_name, p.last_name
),
volunteer_categories AS (
SELECT
person_id,
first_name,
last_name,
days_served,
weeks_served,
TO_CHAR(first_service, 'MM/DD/YY') as first_service_date,
TO_CHAR(last_service, 'MM/DD/YY') as last_service_date,
locations_served,
service_areas,
CURRENT_DATE - DATE(last_service) as days_since_last_service,
CASE
WHEN weeks_served >= 10 THEN 'Core Volunteer'
WHEN weeks_served >= 5 THEN 'Regular Volunteer'
WHEN weeks_served >= 2 THEN 'Occasional Volunteer'
ELSE 'New Volunteer'
END as volunteer_category,
CASE
WHEN CURRENT_DATE - DATE(last_service) > 30 THEN 'Inactive'
WHEN CURRENT_DATE - DATE(last_service) > 14 THEN 'Missing'
ELSE 'Active'
END as status
FROM volunteer_service
)
SELECT
first_name || ' ' || last_name as volunteer_name,
volunteer_category,
status,
days_served,
weeks_served as weeks_served_this_quarter,
first_service_date,
last_service_date,
days_since_last_service,
locations_served as num_areas_served,
service_areas
FROM volunteer_categories
ORDER BY
CASE volunteer_category
WHEN 'Core Volunteer' THEN 1
WHEN 'Regular Volunteer' THEN 2
WHEN 'Occasional Volunteer' THEN 3
ELSE 4
END,
days_served DESC;
```
### Volunteer Recruitment Needs Report
```sql theme={null}
-- Identify areas needing additional volunteers
WITH location_requirements AS (
SELECT
l.location_id,
l.name as location,
l.attendees_per_volunteer as ratio_requirement,
l.min_volunteers as minimum_volunteers,
l.max_occupancy
FROM planning_center.checkins_locations l
-- Keyed off the staffing requirement rather than child_or_adult: rooms that
-- configure a ratio are the ones this report is about, and in practice
-- almost none of them set child_or_adult. Add
-- `AND l.child_or_adult = 'C'` if your church does populate it.
WHERE l.kind = 'Location'
AND (l.attendees_per_volunteer > 0 OR l.min_volunteers > 0) -- 0 means not configured
),
recent_attendance AS (
SELECT
lr.location,
lr.ratio_requirement,
lr.minimum_volunteers,
AVG(daily_children) as avg_children,
AVG(daily_volunteers) as avg_volunteers,
MAX(daily_children) as peak_children,
MIN(daily_volunteers) as min_volunteers_actual
FROM location_requirements lr
LEFT JOIN LATERAL (
SELECT
DATE(c.created_at) as service_date,
COUNT(DISTINCT CASE WHEN c.kind = 'Regular' THEN c.check_in_id END) as daily_children,
COUNT(DISTINCT CASE WHEN c.kind = 'Volunteer' THEN c.check_in_id END) as daily_volunteers
FROM planning_center.checkins_check_ins_relationships cr
JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
WHERE cr.relationship_id = lr.location_id
AND cr.relationship_type = 'Location'
AND c.created_at >= CURRENT_DATE - INTERVAL '4 weeks'
GROUP BY DATE(c.created_at)
) daily_stats ON true
GROUP BY lr.location, lr.ratio_requirement, lr.minimum_volunteers
)
SELECT
location,
ROUND(avg_children, 0) as avg_children,
peak_children,
ROUND(avg_volunteers, 0) as current_avg_volunteers,
CASE
WHEN COALESCE(ratio_requirement, 0) > 0 AND avg_children > 0 THEN
CEIL(avg_children / ratio_requirement)
ELSE minimum_volunteers
END as volunteers_needed,
CASE
WHEN COALESCE(ratio_requirement, 0) > 0 AND peak_children > 0 THEN
CEIL(peak_children / ratio_requirement)
ELSE minimum_volunteers
END as peak_volunteers_needed,
CASE
WHEN COALESCE(ratio_requirement, 0) > 0 AND avg_children > 0 THEN
GREATEST(0, CEIL(avg_children / ratio_requirement) - ROUND(avg_volunteers, 0))
ELSE GREATEST(0, COALESCE(minimum_volunteers, 0) - ROUND(avg_volunteers, 0))
END as additional_volunteers_needed,
CASE
WHEN COALESCE(minimum_volunteers, 0) > 0 AND min_volunteers_actual < minimum_volunteers THEN '🚨 Critical - Below minimum'
WHEN COALESCE(ratio_requirement, 0) > 0 AND avg_volunteers > 0
AND (avg_children / avg_volunteers) > ratio_requirement * 1.2 THEN '⚠️ Often over ratio'
WHEN COALESCE(ratio_requirement, 0) > 0 AND avg_volunteers > 0
AND (avg_children / avg_volunteers) > ratio_requirement THEN '⚠️ At ratio limit'
ELSE '✓ Adequately staffed'
END as staffing_status
FROM recent_attendance
WHERE avg_children > 0 OR avg_volunteers > 0
ORDER BY
additional_volunteers_needed DESC,
location;
```
## Guest Follow-Up Report
### Weekly Guest Follow-Up List
```sql theme={null}
-- Generate follow-up list for guest services team
WITH guest_visits AS (
SELECT
p.person_id,
p.first_name,
p.last_name,
p.gender,
p.birthdate,
c.check_in_id,
c.created_at as visit_date,
c.one_time_guest,
c.emergency_contact_name,
c.emergency_contact_phone_number
FROM planning_center.checkins_people p
JOIN planning_center.checkins_check_ins_relationships cr
ON p.person_id = cr.relationship_id
AND cr.relationship_type = 'Person'
JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
WHERE c.kind = 'Guest'
AND c.created_at >= DATE_TRUNC('week', CURRENT_DATE)
),
guest_summary AS (
SELECT
person_id,
first_name,
last_name,
gender,
CASE
WHEN birthdate IS NOT NULL THEN
EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate))::TEXT || ' years'
ELSE 'Age unknown'
END as age,
COUNT(*) as visits_this_week,
MIN(visit_date) as first_visit_this_week,
BOOL_OR(one_time_guest) as checked_in_without_profile,
STRING_AGG(DISTINCT emergency_contact_name || ' (' || emergency_contact_phone_number || ')', '; ')
FILTER (WHERE emergency_contact_name IS NOT NULL) as emergency_contacts
FROM guest_visits
GROUP BY person_id, first_name, last_name, gender, birthdate
)
SELECT
ROW_NUMBER() OVER (ORDER BY first_visit_this_week) as follow_up_priority,
first_name || ' ' || last_name as guest_name,
gender,
age,
TO_CHAR(first_visit_this_week, 'FMDay, MM/DD') as visit_day,
visits_this_week,
CASE
WHEN checked_in_without_profile THEN 'Guest without a profile'
ELSE 'Guest with a profile'
END as guest_type,
emergency_contacts,
CASE
WHEN checked_in_without_profile THEN 'Create a profile, then send welcome packet'
WHEN visits_this_week > 1 THEN 'Multiple visits - high interest'
ELSE 'Standard follow-up'
END as follow_up_action
FROM guest_summary
ORDER BY
checked_in_without_profile DESC,
first_visit_this_week;
```
## Service Time Optimization Report
### Service Attendance Distribution
```sql theme={null}
-- Analyze attendance patterns to optimize service times
WITH service_patterns AS (
SELECT
TO_CHAR(created_at, 'FMDay') as day_of_week,
TO_CHAR(created_at, 'HH12:00 AM') as service_hour,
DATE(created_at) as service_date,
COUNT(DISTINCT check_in_id) as attendance
FROM planning_center.checkins_check_ins
WHERE created_at >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY TO_CHAR(created_at, 'FMDay'), TO_CHAR(created_at, 'HH12:00 AM'),
DATE(created_at)
),
service_analysis AS (
SELECT
day_of_week,
service_hour,
COUNT(DISTINCT service_date) as num_services,
ROUND(AVG(attendance), 0) as avg_attendance,
MIN(attendance) as min_attendance,
MAX(attendance) as max_attendance,
ROUND(STDDEV(attendance), 1) as attendance_stddev
FROM service_patterns
GROUP BY day_of_week, service_hour
HAVING COUNT(DISTINCT service_date) >= 3
)
SELECT
day_of_week,
service_hour,
num_services as services_held,
avg_attendance,
min_attendance,
max_attendance,
attendance_stddev as variance,
ROUND((max_attendance - min_attendance)::NUMERIC / NULLIF(avg_attendance, 0) * 100, 0) as volatility_pct,
CASE
WHEN avg_attendance < 30 THEN 'Consider combining with another service'
WHEN attendance_stddev > avg_attendance * 0.5 THEN 'High variance - investigate causes'
WHEN max_attendance > avg_attendance * 1.4 THEN 'Occasional overflow - plan accordingly'
ELSE 'Stable attendance pattern'
END as recommendation
FROM service_analysis
ORDER BY
CASE day_of_week
WHEN 'Sunday' THEN 1
WHEN 'Saturday' THEN 2
WHEN 'Wednesday' THEN 3
WHEN 'Thursday' THEN 4
WHEN 'Friday' THEN 5
WHEN 'Tuesday' THEN 6
WHEN 'Monday' THEN 7
END,
service_hour;
```
## Year-End Summary Report
### Annual Ministry Impact Report
```sql theme={null}
-- Comprehensive year-end summary for annual reports
WITH yearly_stats AS (
SELECT
COUNT(DISTINCT check_in_id) as total_checkins,
COUNT(DISTINCT CASE WHEN kind = 'Regular' THEN check_in_id END) as regular_checkins,
COUNT(DISTINCT CASE WHEN kind = 'Guest' THEN check_in_id END) as guest_checkins,
COUNT(DISTINCT CASE WHEN kind = 'Volunteer' THEN check_in_id END) as volunteer_checkins,
COUNT(DISTINCT CASE WHEN one_time_guest = true THEN check_in_id END) as guests_without_profile,
COUNT(DISTINCT DATE(created_at)) as service_days,
COUNT(DISTINCT DATE_TRUNC('week', created_at)) as weeks_with_services
FROM planning_center.checkins_check_ins
WHERE created_at >= DATE_TRUNC('year', CURRENT_DATE)
AND created_at < DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 year'
),
unique_people AS (
SELECT
COUNT(DISTINCT p.person_id) as unique_attendees,
COUNT(DISTINCT CASE WHEN c.kind = 'Volunteer' THEN p.person_id END) as unique_volunteers
FROM planning_center.checkins_people p
JOIN planning_center.checkins_check_ins_relationships cr
ON p.person_id = cr.relationship_id
AND cr.relationship_type = 'Person'
JOIN planning_center.checkins_check_ins c
ON cr.check_in_id = c.check_in_id
WHERE c.created_at >= DATE_TRUNC('year', CURRENT_DATE)
),
peak_attendance AS (
SELECT
DATE(created_at) as peak_date,
COUNT(DISTINCT check_in_id) as peak_count
FROM planning_center.checkins_check_ins
WHERE created_at >= DATE_TRUNC('year', CURRENT_DATE)
GROUP BY DATE(created_at)
ORDER BY COUNT(DISTINCT check_in_id) DESC
LIMIT 1
)
SELECT
'===========================================' as divider1,
TO_CHAR(DATE_TRUNC('year', CURRENT_DATE), 'YYYY') || ' ANNUAL MINISTRY IMPACT REPORT' as report_title,
'===========================================' as divider2,
'' as blank1,
'📊 ATTENDANCE OVERVIEW' as section1,
'-------------------------------------------' as divider3,
ys.total_checkins as total_annual_checkins,
up.unique_attendees as unique_individuals,
ys.service_days as days_with_services,
ys.weeks_with_services as weeks_with_services,
ROUND(ys.total_checkins::NUMERIC / NULLIF(ys.service_days, 0), 0) as avg_daily_attendance,
ROUND(ys.total_checkins::NUMERIC / NULLIF(ys.weeks_with_services, 0), 0) as avg_weekly_attendance,
'' as blank2,
'👥 ATTENDANCE BREAKDOWN' as section2,
'-------------------------------------------' as divider4,
ys.regular_checkins as regular_attendee_checkins,
ys.guest_checkins as guest_checkins,
ys.guests_without_profile as guests_without_profile,
ROUND((ys.guest_checkins::NUMERIC / NULLIF(ys.total_checkins, 0)) * 100, 1) as guest_percentage,
'' as blank3,
'🙋 VOLUNTEER IMPACT' as section3,
'-------------------------------------------' as divider5,
ys.volunteer_checkins as total_volunteer_checkins,
up.unique_volunteers as unique_volunteers,
ROUND(ys.volunteer_checkins::NUMERIC / NULLIF(ys.weeks_with_services, 0), 0) as avg_volunteers_per_week,
'' as blank4,
'🏆 PEAK ATTENDANCE' as section4,
'-------------------------------------------' as divider6,
TO_CHAR(pa.peak_date, 'FMMonth DD, YYYY') as highest_attendance_date,
pa.peak_count as highest_attendance_count,
'' as blank5,
'===========================================' as divider7
FROM yearly_stats ys, unique_people up, peak_attendance pa;
```
## Export Tips
These reports can be exported in various formats:
1. **CSV Export**: Add `\copy (SELECT ...) TO 'report.csv' CSV HEADER;`
2. **Excel-Ready**: Most results can be copied directly into Excel
3. **Automated Delivery**: Schedule these queries to run weekly/monthly
4. **Dashboard Integration**: Use these queries as data sources for BI tools
## Next Steps
* Review the [Data Model](/planning-center/check-ins/data-model) for complete field documentation
* Check [Advanced Queries](/planning-center/check-ins/advanced-queries) for more complex analysis techniques
* Return to [Basic Queries](/planning-center/check-ins/basic-queries) for simpler examples
# Engagement Scoring
Source: https://docs.getparable.io/planning-center/engagement-scoring
How Parable measures recent engagement, and how to tune it for your church
## What It Is
Parable's engagement score is a single 0–100 number describing how connected a
person has been to the life of your church **recently**. Alongside the score,
every person gets:
* an **engagement tier** — where they sit today
* a **warning light** — whether they need a look from your team
* a **trend** — whether their score is rising, falling, or flat
The goal is not to replace pastoral wisdom. It is to help your team see quickly
who is deeply connected, who may be drifting, and who may need follow-up.
Engagement scoring is configured under **Settings → Engagement**. Every
default on this page can be changed there.
## How the Score Is Built
Scoring runs in four steps.
Parable turns synced Planning Center records into dated **activity events** —
one per check-in, donation, group membership, serving assignment, form
submission, registration, or message read.
Each event is worth **base points** on the day it happens and loses value
smoothly over time, following an exponential decay curve.
The decayed points in each category are converted to a 0–100 category score.
Category scores are combined using your configured weights to produce the
overall 0–100 score.
### The Decay Formula
A single event contributes:
```text theme={null}
points = base_points × e^(−λ × hours_since_event)
```
`λ` (lambda) is derived from the category's **half-life** — the number of days
after which an event is worth half what it was:
```text theme={null}
λ = ln(2) ÷ (half_life_days × 24)
```
So with Giving's 70-day half-life, a \$200 gift counts fully the week it lands,
half as much ten weeks later, and a quarter as much twenty weeks after that. It
never quite reaches zero, but it fades out of relevance.
### Turning Points Into a Category Score
For every category except Groups:
```text theme={null}
category_score = min(total_decayed_points ÷ base_points × 100, 100)
```
In practice a category maxes out at 100 once a person has roughly one full-value
event's worth of undecayed points — so sustained activity holds a category near
its ceiling, while a single old event sits low.
**Groups is scored differently**, because belonging and showing up are different
signals:
* Group **membership** is worth up to **35 points**
* Group **event attendance** is worth up to **65 points**, reaching the full
bonus at about **4 attended group events**
* The two are added together and capped at 100
### Blending Into the Overall Score
```text theme={null}
total = Σ (category_score × weight) ÷ Σ weight
```
Dividing by the total weight means disabling a category re-normalizes the rest
rather than dragging everyone's score down. The result is capped at 100.
## What Counts Toward the Score
| Category | Default weight | Base points | Half-life | What counts |
| -------------- | -------------- | ----------- | --------- | ----------------------------------------------------------------------------------------------------- |
| Attendance | 10% | 100 | 35 days | Check-ins tied to a person, plus **caregiver check-ins** — a parent who checks in a child gets credit |
| Giving | 25% | 150 | 70 days | Donations with payment status `succeeded` |
| Groups | 25% | 100 | 35 days | Joining a group, and attending group events where attendance was recorded |
| Serving | 20% | 100 | 49 days | Plan people with a **confirmed** status, dated from when the status was last updated |
| Forms | 5% | 100 | 35 days | Form submissions tied to a person |
| Registrations | 10% | 100 | 35 days | Active, non-canceled attendee records tied to a person |
| Communications | 5% | 50 | 10 days | Messages with a recorded **read** timestamp |
Weights, base points, half-lives, and whether a category counts at all are
per-organization settings.
### What Does Not Count
* People whose Planning Center status is not `active` are excluded from regular
scoring and are placed in the **Inactive** tier instead.
* Donations that did not succeed, and donations not linked to a person.
* Canceled or inactive registrations, and attendees that can't be tied to a person.
* Messages that were never opened — Communications requires a read receipt.
* Group events with no recorded attendance.
* Activity dated before the person existed in Planning Center.
### Why Categories Fade at Different Speeds
The half-lives encode how long each signal stays meaningful:
* **Giving (70 days)** fades slowest — giving is periodic, so a monthly giver
should not look disengaged in week three.
* **Serving (49 days)** is next, matching typical volunteer rotations.
* **Attendance, Groups, Forms, Registrations (35 days)** sit in the middle.
* **Communications (10 days)** fades fastest — opening an email is a weak,
short-lived signal, and a longer half-life would let it mask real absence.
## Engagement Tiers
Tiers are cutoffs on the overall score. The defaults are:
| Tier | Default score range | What it usually means |
| -------------- | ------------------- | ------------------------------------------------------------------------------------- |
| Highly Engaged | `40+` | Strong, recent involvement across church life |
| Engaged | `20–39.99` | Healthy involvement |
| Moderate | `10–19.99` | Some regular connection |
| Low | `1–9.99` | Limited recent connection |
| Disengaged | Below `1` | Very little recent activity |
| Inactive | — | Assigned regardless of score when the person's Planning Center status is not `active` |
These thresholds look low compared with a school grade, and that is
deliberate. Because every signal decays, a score near 40 already means
consistent, recent activity across several categories. Tune the cutoffs under
**Settings → Engagement → Tier Thresholds** to match how your church talks
about involvement.
## Warning Lights
The warning light answers a different question from the tier: not "how involved
is this person?" but "has something changed?"
Parable evaluates two independent signals and takes the more concerning of the
two.
### Signal 1 — Activity Heartbeat
How long since the person did anything at all:
| Time since last activity | Light |
| ------------------------ | --------------- |
| Under 4 weeks | Green |
| 4+ weeks | Yellow |
| 8+ weeks | Red |
| 90+ days | Orange (Lapsed) |
| 180+ days | Slate (Dormant) |
The 4- and 8-week thresholds are configurable (1–12 weeks, yellow ≤ red). The
90- and 180-day Lapsed and Dormant gates are fixed.
### Signal 2 — Trend Against Their Own Baseline
Parable tracks each person against **their own** normal, not the congregation's:
* **EMA** — an exponential moving average of the daily score, which smooths out
day-to-day noise. Each day it moves 5% of the way toward the current score.
* **Baseline** — the rolling average of the person's monthly score snapshots
(default: the last 6 months, requiring at least 3 snapshots).
Once someone is past the grace period, a missing baseline no longer holds them
at White — they are judged on the activity heartbeat alone until enough monthly
snapshots exist for the trend signal to switch on.
| Recent EMA vs. baseline | Light |
| ----------------------- | ------ |
| At or above 75% | Green |
| Below 75% | Yellow |
| Below 50% | Red |
This is what catches the faithful attender who quietly halves their involvement
while still technically showing up — the heartbeat signal alone would miss them.
### The Neutral States
| Light | Label | Meaning |
| ------ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Gray | No Data | No activity has ever been recorded for this person |
| White | New | Has activity but no baseline yet, **and** either is still inside the new-member grace period (default 90 days) or has no Planning Center created date |
| Green | Healthy | Neither signal has crossed a threshold |
| Yellow | Needs Attention | Fading on at least one signal |
| Red | Urgent | Significantly absent or significantly down |
| Orange | Lapsed | 90+ days with no activity |
| Slate | Dormant | 180+ days with no activity |
Yellow, Red, Orange, and Slate are the four statuses that appear in engagement
alerts. Gray, White, and Green never raise an alert.
A warning light is a prompt for a conversation, not a verdict. It tells your
team where a personal check-in may be worth the time.
## When Scoring Runs
* **Daily** — every enabled organization is rescored, the EMA advances, and
warning lights are recalculated. This runs on its own schedule, independent of
whether a Planning Center sync completed that day.
* **Monthly** — a permanent score snapshot is written for every person. These
snapshots are what the baseline is computed from, which is why a brand-new
person shows a White light until a few months of history exist.
## Writing Scores Back to Planning Center
Under **Settings → Engagement → Planning Center Sync** you can push engagement
data back into Planning Center as custom fields on each person. Two independent
toggles are available:
* **Person Engagement** — writes the engagement tier and warning light
* **Giving Engagement** — writes the giving engagement classification
Parable creates and maintains its own tab and field definitions in Planning
Center, and syncs run automatically after each calculation. Both toggles are off
until you enable them.
## Related Guides
* For a broad view of your church data, start with [Planning Center Overview](/planning-center/index).
* For people-focused analysis, see [People Overview](/planning-center/people/overview).
* For giving-specific engagement, see [Giving Stages](/guides/giving-engagement/giving-stages).
* For custom SQL examples, see [People Advanced Queries](/planning-center/people/advanced-queries),
[Groups Advanced Queries](/planning-center/groups/advanced-queries), and
[Services Reporting Examples](/planning-center/services/reporting-examples).
# Advanced Planning Center Giving Queries
Source: https://docs.getparable.io/planning-center/giving/advanced-queries
Advanced Giving SQL for churches: analyze donor retention, compare year-over-year trends, and segment your donors with CTEs and window functions.
Ready to unlock deeper insights from your giving data? These advanced queries will help you perform complex analysis, track trends, and generate sophisticated reports.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Giving module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.giving_donations`
❌ INCORRECT: `SELECT * FROM giving_donations`
### Row Level Security (RLS)
Row Level Security automatically scopes results by:
* **tenant\_organization\_id** – only data from your organization
* **system\_status** – active records returned by default
**Do not add these filters manually**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Keep your attention on domain-specific filters (date ranges, refunded flags, fund logic) while RLS handles tenancy and system status.
## Table of Contents
* [Complex Joins and Relationships](#complex-joins-and-relationships)
* [Time-Based Analysis](#time-based-analysis)
* [Donor Segmentation](#donor-segmentation)
* [Recurring Giving Analysis](#recurring-giving-analysis)
* [Pledge Campaign Tracking](#pledge-campaign-tracking)
* [Window Functions and Rankings](#window-functions-and-rankings)
* [Performance Optimization](#performance-optimization)
## Complex Joins and Relationships
### Complete Donation Details with All Relationships
This query brings together donations, donors, funds, batches, and campuses:
```sql theme={null}
-- Comprehensive donation view with all related data
WITH donation_details AS (
SELECT
d.donation_id,
d.amount_cents / 100.0 as donation_amount,
d.payment_method,
d.received_at,
d.fee_cents / 100.0 as fee_amount,
d.refunded,
-- Get person details
p.person_id,
p.first_name,
p.last_name,
p.donor_number,
-- Get batch details
b.batch_id,
b.description as batch_description,
b.total_cents / 100.0 as batch_total,
-- Get campus details
c.campus_id,
c.name as campus_name
FROM planning_center.giving_donations d
-- Join to person
LEFT JOIN planning_center.giving_donations_relationships dr_person
ON d.donation_id = dr_person.donation_id
AND dr_person.relationship_type = 'Person'
LEFT JOIN planning_center.giving_people p
ON dr_person.relationship_id = p.person_id
-- Join to batch
LEFT JOIN planning_center.giving_donations_relationships dr_batch
ON d.donation_id = dr_batch.donation_id
AND dr_batch.relationship_type = 'Batch'
LEFT JOIN planning_center.giving_batches b
ON dr_batch.relationship_id = b.batch_id
-- Join to campus
LEFT JOIN planning_center.giving_donations_relationships dr_campus
ON d.donation_id = dr_campus.donation_id
AND dr_campus.relationship_type = 'Campus'
LEFT JOIN planning_center.giving_campuses c
ON dr_campus.relationship_id = c.campus_id
)
SELECT * FROM donation_details
WHERE received_at >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY received_at DESC;
```
### Multi-Fund Donations Analysis
Find donations that were split across multiple funds:
```sql theme={null}
-- Donations split across multiple funds
WITH donation_fund_counts AS (
SELECT
d.donation_id,
d.amount_cents / 100.0 as total_amount,
d.received_at,
COUNT(DISTINCT f.fund_id) as num_funds,
STRING_AGG(f.name, ', ' ORDER BY des.amount_cents DESC) as fund_names
FROM planning_center.giving_donations d
-- Join to designations via donation_relationships (type='Designation')
JOIN planning_center.giving_donations_relationships dr_des
ON d.donation_id = dr_des.donation_id
AND dr_des.relationship_type = 'Designation'
JOIN planning_center.giving_designations des
ON dr_des.relationship_id = des.designation_id
-- Join to funds via designation_relationships (type='Fund')
JOIN planning_center.giving_designations_relationships desr
ON des.designation_id = desr.designation_id
AND desr.relationship_type = 'Fund'
JOIN planning_center.giving_funds f
ON desr.relationship_id = f.fund_id
WHERE d.received_at >= CURRENT_DATE - INTERVAL '90 days'
AND d.refunded = false
GROUP BY d.donation_id, d.amount_cents, d.received_at
)
SELECT *
FROM donation_fund_counts
WHERE num_funds > 1 -- Only multi-fund donations
ORDER BY total_amount DESC;
```
## Time-Based Analysis
### Year-Over-Year Comparison by Month
```sql theme={null}
-- Compare giving by month across multiple years
WITH monthly_giving AS (
SELECT
DATE_TRUNC('month', received_at) as month,
EXTRACT(YEAR FROM received_at) as year,
EXTRACT(MONTH FROM received_at) as month_num,
TO_CHAR(received_at, 'FMMonth') as month_name,
COUNT(*) as donation_count,
COUNT(DISTINCT dr.relationship_id) as unique_donors,
SUM(d.amount_cents) / 100.0 as total_amount
FROM planning_center.giving_donations d
LEFT JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
WHERE d.refunded = false
AND d.received_at >= DATE_TRUNC('year', CURRENT_DATE - INTERVAL '2 years')
GROUP BY DATE_TRUNC('month', received_at),
EXTRACT(YEAR FROM received_at),
EXTRACT(MONTH FROM received_at),
TO_CHAR(received_at, 'FMMonth')
)
SELECT
month_name,
month_num,
MAX(CASE WHEN year = EXTRACT(YEAR FROM CURRENT_DATE) - 2 THEN total_amount END) as two_years_ago,
MAX(CASE WHEN year = EXTRACT(YEAR FROM CURRENT_DATE) - 1 THEN total_amount END) as last_year,
MAX(CASE WHEN year = EXTRACT(YEAR FROM CURRENT_DATE) THEN total_amount END) as this_year,
-- Calculate year-over-year growth
ROUND(
((MAX(CASE WHEN year = EXTRACT(YEAR FROM CURRENT_DATE) THEN total_amount END) /
NULLIF(MAX(CASE WHEN year = EXTRACT(YEAR FROM CURRENT_DATE) - 1 THEN total_amount END), 0)) - 1) * 100,
2
) as yoy_growth_percent
FROM monthly_giving
GROUP BY month_name, month_num
ORDER BY month_num;
```
### Rolling 12-Month Trends
```sql theme={null}
-- Calculate 12-month rolling average
WITH monthly_totals AS (
SELECT
DATE_TRUNC('month', received_at) as month,
SUM(amount_cents) / 100.0 as monthly_total,
COUNT(*) as donation_count
FROM planning_center.giving_donations
WHERE refunded = false
AND received_at >= CURRENT_DATE - INTERVAL '24 months'
GROUP BY DATE_TRUNC('month', received_at)
),
rolling_averages AS (
SELECT
month,
monthly_total,
donation_count,
AVG(monthly_total) OVER (
ORDER BY month
ROWS BETWEEN 11 PRECEDING AND CURRENT ROW
) as rolling_12m_avg,
SUM(monthly_total) OVER (
ORDER BY month
ROWS BETWEEN 11 PRECEDING AND CURRENT ROW
) as rolling_12m_total
FROM monthly_totals
)
SELECT
month,
monthly_total,
rolling_12m_avg,
rolling_12m_total,
ROUND(((monthly_total / NULLIF(rolling_12m_avg, 0)) - 1) * 100, 2) as pct_vs_12m_avg
FROM rolling_averages
WHERE month >= CURRENT_DATE - INTERVAL '12 months'
ORDER BY month DESC;
```
## Donor Segmentation
### Donor Lifecycle Analysis
Categorize donors by their giving patterns:
```sql theme={null}
-- Segment donors by giving frequency and recency
WITH donor_metrics AS (
SELECT
p.person_id,
p.first_name,
p.last_name,
COUNT(DISTINCT d.donation_id) as total_donations,
SUM(d.amount_cents) / 100.0 as lifetime_giving,
MIN(d.received_at) as first_donation_date,
MAX(d.received_at) as last_donation_date,
CURRENT_DATE - MAX(d.received_at)::date as days_since_last_donation,
COUNT(DISTINCT DATE_TRUNC('month', d.received_at)) as months_given
FROM planning_center.giving_people p
JOIN planning_center.giving_donations_relationships dr
ON p.person_id = dr.relationship_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d
ON dr.donation_id = d.donation_id
WHERE d.refunded = false
GROUP BY p.person_id, p.first_name, p.last_name
),
donor_segments AS (
SELECT
*,
CASE
WHEN days_since_last_donation <= 90 AND months_given >= 10 THEN 'Champion'
WHEN days_since_last_donation <= 90 AND months_given >= 6 THEN 'Loyal'
WHEN days_since_last_donation <= 90 AND months_given >= 3 THEN 'Developing'
WHEN days_since_last_donation <= 90 THEN 'New'
WHEN days_since_last_donation <= 180 THEN 'At Risk'
WHEN days_since_last_donation <= 365 THEN 'Lapsed'
ELSE 'Lost'
END as donor_segment,
CASE
WHEN lifetime_giving >= 10000 THEN 'Major'
WHEN lifetime_giving >= 5000 THEN 'Mid-Level'
WHEN lifetime_giving >= 1000 THEN 'Regular'
ELSE 'Small'
END as giving_level
FROM donor_metrics
)
SELECT
donor_segment,
giving_level,
COUNT(*) as donor_count,
AVG(lifetime_giving) as avg_lifetime_giving,
SUM(lifetime_giving) as total_lifetime_giving
FROM donor_segments
GROUP BY donor_segment, giving_level
ORDER BY donor_segment, giving_level DESC;
```
### First-Time Donor Retention
Track how many first-time donors give again:
```sql theme={null}
-- First-time donor retention analysis
WITH first_donations AS (
SELECT
dr.relationship_id as person_id,
MIN(d.received_at) as first_donation_date
FROM planning_center.giving_donations d
JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
WHERE d.refunded = false
GROUP BY dr.relationship_id
),
second_donations AS (
SELECT
fd.person_id,
fd.first_donation_date,
MIN(d.received_at) as second_donation_date
FROM first_donations fd
JOIN planning_center.giving_donations_relationships dr
ON fd.person_id = dr.relationship_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d
ON dr.donation_id = d.donation_id
WHERE d.received_at > fd.first_donation_date
AND d.refunded = false
GROUP BY fd.person_id, fd.first_donation_date
)
SELECT
DATE_TRUNC('month', fd.first_donation_date) as cohort_month,
COUNT(DISTINCT fd.person_id) as first_time_donors,
COUNT(DISTINCT sd.person_id) as retained_donors,
ROUND(COUNT(DISTINCT sd.person_id) * 100.0 / COUNT(DISTINCT fd.person_id), 2) as retention_rate,
AVG(sd.second_donation_date - sd.first_donation_date) as avg_days_to_second_donation
FROM first_donations fd
LEFT JOIN second_donations sd ON fd.person_id = sd.person_id
WHERE fd.first_donation_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', fd.first_donation_date)
ORDER BY cohort_month DESC;
```
## Recurring Giving Analysis
### Active Recurring Donors
```sql theme={null}
-- Analyze active recurring donations
-- Person is found via donations that link to the recurring donation
SELECT DISTINCT ON (rd.recurring_donation_id)
rd.recurring_donation_id,
rd.amount_cents / 100.0 as recurring_amount,
rd.schedule,
rd.status,
rd.created_at as setup_date,
rd.last_donation_received_at,
p.first_name,
p.last_name
FROM planning_center.giving_recurring_donations rd
-- Find a donation linked to this recurring donation
JOIN planning_center.giving_donations_relationships dr_rd
ON dr_rd.relationship_id = rd.recurring_donation_id
AND dr_rd.relationship_type = 'RecurringDonation'
-- Get the person from that same donation
JOIN planning_center.giving_donations_relationships dr_p
ON dr_rd.donation_id = dr_p.donation_id
AND dr_p.relationship_type = 'Person'
JOIN planning_center.giving_people p
ON dr_p.relationship_id = p.person_id
WHERE rd.status = 'active'
ORDER BY rd.recurring_donation_id, rd.amount_cents DESC;
```
### Recurring Giving Health Metrics
```sql theme={null}
-- Key metrics for recurring giving program
-- Note: schedule is stored as JSONB; frequency is not a direct column.
-- Use amount_cents as a proxy for expected value (monthly equivalent assumes 12x/year).
WITH recurring_metrics AS (
SELECT
COUNT(DISTINCT rd.recurring_donation_id) as active_recurring_count,
SUM(rd.amount_cents / 100.0) as total_active_amount,
AVG(rd.amount_cents / 100.0) as avg_recurring_amount
FROM planning_center.giving_recurring_donations rd
WHERE rd.status = 'active'
),
churn_metrics AS (
SELECT
COUNT(*) as churned_last_90_days
FROM planning_center.giving_recurring_donations
WHERE status IN ('indefinite_hold', 'temporary_hold')
AND updated_at >= CURRENT_DATE - INTERVAL '90 days'
)
SELECT
rm.active_recurring_count,
rm.total_active_amount,
rm.avg_recurring_amount,
cm.churned_last_90_days,
ROUND(cm.churned_last_90_days * 100.0 / NULLIF(rm.active_recurring_count + cm.churned_last_90_days, 0), 2) as churn_rate_90d
FROM recurring_metrics rm
CROSS JOIN churn_metrics cm;
```
## Pledge Campaign Tracking
### Campaign Progress Dashboard
```sql theme={null}
-- Track pledge campaign progress
WITH campaign_summary AS (
SELECT
pc.pledge_campaign_id,
pc.name as campaign_name,
pc.description,
pc.goal_cents / 100.0 as campaign_goal,
-- received_total_from_pledges_cents + received_total_outside_of_pledges_cents = total received
(COALESCE(pc.received_total_from_pledges_cents, 0) +
COALESCE(pc.received_total_outside_of_pledges_cents, 0)) / 100.0 as total_received,
COUNT(DISTINCT pr.pledge_id) as pledge_count,
SUM(COALESCE(p.amount_cents, 0)) / 100.0 as total_pledged
FROM planning_center.giving_pledge_campaigns pc
-- Pledges connect to campaigns via pledge_relationships (type='PledgeCampaign' if data exists)
-- or can be queried separately and aggregated
LEFT JOIN planning_center.giving_pledges_relationships pr
ON pr.relationship_type = 'PledgeCampaign'
AND pr.relationship_id = pc.pledge_campaign_id
LEFT JOIN planning_center.giving_pledges p
ON p.pledge_id = pr.pledge_id
GROUP BY pc.pledge_campaign_id, pc.name, pc.description,
pc.goal_cents, pc.received_total_from_pledges_cents,
pc.received_total_outside_of_pledges_cents
)
SELECT
campaign_name,
campaign_goal,
total_pledged,
total_received,
pledge_count,
ROUND((total_pledged / NULLIF(campaign_goal, 0)) * 100, 2) as percent_pledged,
ROUND((total_received / NULLIF(total_pledged, 0)) * 100, 2) as fulfillment_rate,
campaign_goal - total_received as remaining_to_goal
FROM campaign_summary
ORDER BY campaign_goal DESC;
```
### Individual Pledge Tracking
```sql theme={null}
-- Track individual pledge fulfillment
-- donated_total_cents tracks how much has been received toward a pledge
SELECT
p.pledge_id,
per.first_name,
per.last_name,
p.amount_cents / 100.0 as pledge_amount,
p.donated_total_cents / 100.0 as amount_received,
pc.name as campaign_name,
p.created_at as pledge_date,
ROUND((p.donated_total_cents * 100.0 / NULLIF(p.amount_cents, 0)), 2) as percent_fulfilled,
(p.amount_cents - COALESCE(p.donated_total_cents, 0)) / 100.0 as remaining_balance
FROM planning_center.giving_pledges p
-- Join to campaign via pledge_relationships (type='PledgeCampaign')
JOIN planning_center.giving_pledges_relationships pr_campaign
ON p.pledge_id = pr_campaign.pledge_id
AND pr_campaign.relationship_type = 'PledgeCampaign'
JOIN planning_center.giving_pledge_campaigns pc
ON pr_campaign.relationship_id = pc.pledge_campaign_id
-- Join to person via pledge_relationships (type='Person')
JOIN planning_center.giving_pledges_relationships pr
ON p.pledge_id = pr.pledge_id
AND pr.relationship_type = 'Person'
JOIN planning_center.giving_people per
ON pr.relationship_id = per.person_id
WHERE (p.amount_cents - COALESCE(p.donated_total_cents, 0)) > 0 -- Outstanding pledges only
ORDER BY remaining_balance DESC;
```
## Window Functions and Rankings
### Top Donors by Percentile
```sql theme={null}
-- Rank donors by giving and show percentiles
WITH donor_totals AS (
SELECT
p.person_id,
p.first_name,
p.last_name,
SUM(d.amount_cents) / 100.0 as total_given,
COUNT(d.donation_id) as donation_count
FROM planning_center.giving_people p
JOIN planning_center.giving_donations_relationships dr
ON p.person_id = dr.relationship_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d
ON dr.donation_id = d.donation_id
WHERE d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
AND d.refunded = false
GROUP BY p.person_id, p.first_name, p.last_name
),
ranked_donors AS (
SELECT
*,
RANK() OVER (ORDER BY total_given DESC) as giving_rank,
NTILE(100) OVER (ORDER BY total_given DESC) as percentile,
SUM(total_given) OVER (ORDER BY total_given DESC) as cumulative_total,
SUM(total_given) OVER () as grand_total
FROM donor_totals
)
SELECT
giving_rank,
first_name,
last_name,
total_given,
donation_count,
percentile,
ROUND((cumulative_total / grand_total) * 100, 2) as cumulative_percent_of_total
FROM ranked_donors
WHERE percentile >= 90 -- Top 10% of donors
ORDER BY giving_rank;
```
### Fund Growth Trends with Moving Averages
```sql theme={null}
-- Track fund performance with smoothed trends
WITH daily_fund_totals AS (
SELECT
f.fund_id,
f.name as fund_name,
DATE(d.received_at) as donation_date,
SUM(des.amount_cents) / 100.0 as daily_total
FROM planning_center.giving_funds f
-- Join funds to designations via designation_relationships (type='Fund')
JOIN planning_center.giving_designations_relationships desr
ON desr.relationship_id = f.fund_id
AND desr.relationship_type = 'Fund'
JOIN planning_center.giving_designations des
ON des.designation_id = desr.designation_id
-- Join designations to donations via donation_relationships (type='Designation')
JOIN planning_center.giving_donations_relationships dr_des
ON dr_des.relationship_id = des.designation_id
AND dr_des.relationship_type = 'Designation'
JOIN planning_center.giving_donations d ON dr_des.donation_id = d.donation_id
WHERE d.received_at >= CURRENT_DATE - INTERVAL '90 days'
AND d.refunded = false
GROUP BY f.fund_id, f.name, DATE(d.received_at)
),
fund_trends AS (
SELECT
fund_id,
fund_name,
donation_date,
daily_total,
AVG(daily_total) OVER (
PARTITION BY fund_id
ORDER BY donation_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as seven_day_avg,
AVG(daily_total) OVER (
PARTITION BY fund_id
ORDER BY donation_date
ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
) as thirty_day_avg
FROM daily_fund_totals
)
SELECT
fund_name,
donation_date,
daily_total,
ROUND(seven_day_avg, 2) as seven_day_avg,
ROUND(thirty_day_avg, 2) as thirty_day_avg,
ROUND(((seven_day_avg / NULLIF(thirty_day_avg, 0)) - 1) * 100, 2) as trend_direction_pct
FROM fund_trends
WHERE donation_date >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY fund_name, donation_date DESC;
```
## Performance Optimization
### Using CTEs for Complex Queries
```sql theme={null}
-- Optimized query using multiple CTEs to break down complexity
WITH active_donors AS (
-- First, identify active donors
SELECT DISTINCT dr.relationship_id as person_id
FROM planning_center.giving_donations_relationships dr
JOIN planning_center.giving_donations d ON dr.donation_id = d.donation_id
WHERE dr.relationship_type = 'Person'
AND d.received_at >= CURRENT_DATE - INTERVAL '365 days'
AND d.refunded = false
),
donor_stats AS (
-- Calculate statistics only for active donors
SELECT
ad.person_id,
COUNT(d.donation_id) as donation_count,
SUM(d.amount_cents) / 100.0 as total_given,
AVG(d.amount_cents) / 100.0 as avg_donation
FROM active_donors ad
JOIN planning_center.giving_donations_relationships dr
ON ad.person_id = dr.relationship_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d ON dr.donation_id = d.donation_id
WHERE d.refunded = false
GROUP BY ad.person_id
)
-- Final result with person details
SELECT
p.first_name,
p.last_name,
ds.donation_count,
ds.total_given,
ds.avg_donation
FROM donor_stats ds
JOIN planning_center.giving_people p ON ds.person_id = p.person_id
WHERE ds.total_given >= 1000 -- Major donors only
ORDER BY ds.total_given DESC;
```
### Efficient Date Range Queries
```sql theme={null}
-- Use date functions efficiently for better performance
-- Good: Uses index-friendly date comparison
SELECT COUNT(*), SUM(amount_cents) / 100.0 as total
FROM planning_center.giving_donations
WHERE received_at >= DATE_TRUNC('month', CURRENT_DATE)
AND received_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month';
-- Alternative: Using date generation for reports
WITH date_series AS (
SELECT generate_series(
DATE_TRUNC('week', CURRENT_DATE - INTERVAL '12 weeks'),
DATE_TRUNC('week', CURRENT_DATE),
'1 week'::interval
) as week_start
)
SELECT
ds.week_start,
COALESCE(SUM(d.amount_cents) / 100.0, 0) as weekly_total
FROM date_series ds
LEFT JOIN planning_center.giving_donations d
ON d.received_at >= ds.week_start
AND d.received_at < ds.week_start + INTERVAL '1 week'
AND d.refunded = false
GROUP BY ds.week_start
ORDER BY ds.week_start DESC;
```
## Best Practices for Advanced Queries
### 1. Use CTEs for Readability
Break complex queries into logical steps using Common Table Expressions (WITH clauses).
### 2. Optimize JOIN Order
Join smaller result sets first, then join to larger tables.
### 3. Use Appropriate Indexes
The relationship\_type and relationship\_id columns are indexed for efficient joins.
### 4. Aggregate Early
When possible, aggregate data in CTEs before joining to reduce the working set size.
### 5. Handle NULL Values
Always consider NULL values in calculations and use NULLIF to prevent division by zero.
## Next Steps
* Review [Reporting Examples](/planning-center/giving/reporting-examples) for complete, production-ready reports
* Check the [Data Model](/planning-center/giving/data-model) for detailed table documentation
* Return to [Basic Queries](/planning-center/giving/basic-queries) to review fundamentals
# Basic Planning Center Giving Queries
Source: https://docs.getparable.io/planning-center/giving/basic-queries
Start querying Planning Center Giving data: the most recent donations, gifts over a threshold, donors by name, and giving from the last 30 days.
Start here to learn the fundamentals of querying your church's giving data. Each example builds on the previous one, helping you gain confidence with SQL.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Giving module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.giving_donations`
❌ INCORRECT: `SELECT * FROM giving_donations`
### Row Level Security (RLS)
Row Level Security automatically scopes results by:
* **tenant\_organization\_id** – only your organization's data
* **system\_status** – only active records by default
**Do not add these filters yourself**—RLS already enforces them and redundant predicates can slow queries or hide data you expect to see:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus on donation-specific filters (date ranges, refunded status, funds) while trusting RLS to handle tenancy and system status.
## Table of Contents
* [Viewing Recent Donations](#viewing-recent-donations)
* [Finding Donors](#finding-donors)
* [Working with Funds](#working-with-funds)
* [Date-Based Queries](#date-based-queries)
* [Payment Methods](#payment-methods)
* [Basic Aggregations](#basic-aggregations)
## Viewing Recent Donations
### See Your Latest Donations
```sql theme={null}
-- View the 20 most recent donations
SELECT
donation_id,
amount_cents / 100.0 as amount, -- Convert cents to dollars
amount_currency,
payment_method,
received_at,
refunded -- true if this donation was refunded
FROM planning_center.giving_donations
WHERE received_at IS NOT NULL -- Only completed donations
ORDER BY received_at DESC
LIMIT 20;
```
### Filter by Donation Amount
```sql theme={null}
-- Find all donations over $500
SELECT
donation_id,
amount_cents / 100.0 as amount,
payment_method,
received_at
FROM planning_center.giving_donations
WHERE amount_cents >= 50000 -- $500 in cents
AND received_at IS NOT NULL
AND refunded = false -- Exclude refunded donations
ORDER BY amount_cents DESC;
```
## Finding Donors
### List All Active Donors
```sql theme={null}
-- Get all people who have given
SELECT
person_id,
first_name,
last_name,
donor_number
FROM planning_center.giving_people
ORDER BY last_name, first_name;
```
### Search for a Specific Donor
```sql theme={null}
-- Find donors by name
SELECT
person_id,
first_name,
last_name,
donor_number
FROM planning_center.giving_people
WHERE LOWER(last_name) LIKE '%smith%' -- Case-insensitive search
OR LOWER(first_name) LIKE '%john%'
ORDER BY last_name, first_name;
```
### Connect Donations to Donors
This query shows how to link donations with donor information using the relationship table:
```sql theme={null}
-- See donations with donor names
SELECT
d.donation_id,
d.amount_cents / 100.0 as amount,
d.received_at,
p.first_name,
p.last_name,
p.donor_number
FROM planning_center.giving_donations d
-- Join through the relationship table
JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person' -- Specify we want the Person relationship
-- Join to the people table
JOIN planning_center.giving_people p
ON dr.relationship_id = p.person_id
WHERE d.received_at >= CURRENT_DATE - INTERVAL '30 days' -- Last 30 days
ORDER BY d.received_at DESC
LIMIT 50;
```
## Working with Funds
### List All Available Funds
```sql theme={null}
-- See all your church's funds
SELECT
fund_id,
name,
description,
is_default, -- true for your general/default fund
visibility -- 'everywhere', 'admin_only', etc.
FROM planning_center.giving_funds
WHERE visibility = 'everywhere' -- Only publicly visible funds
ORDER BY name;
```
### See How Donations Are Designated
```sql theme={null}
-- View designations (how donations are allocated to funds)
-- Funds are linked via giving_designations_relationships (type='Fund')
SELECT
des.designation_id,
des.amount_cents / 100.0 as amount,
f.name as fund_name,
f.description as fund_description
FROM planning_center.giving_designations des
JOIN planning_center.giving_designations_relationships desr
ON des.designation_id = desr.designation_id
AND desr.relationship_type = 'Fund'
JOIN planning_center.giving_funds f
ON desr.relationship_id = f.fund_id
ORDER BY des.amount_cents DESC
LIMIT 100;
```
### Connect Donations to Their Fund Designations
```sql theme={null}
-- See how each donation was designated to funds
SELECT
d.donation_id,
d.amount_cents / 100.0 as total_donation,
des.amount_cents / 100.0 as designated_amount,
f.name as fund_name
FROM planning_center.giving_donations d
-- Join to designations via donation_relationships (type='Designation')
JOIN planning_center.giving_donations_relationships dr_des
ON d.donation_id = dr_des.donation_id
AND dr_des.relationship_type = 'Designation'
-- Join to designations
JOIN planning_center.giving_designations des
ON dr_des.relationship_id = des.designation_id
-- Join to funds via designation_relationships (type='Fund')
JOIN planning_center.giving_designations_relationships desr
ON des.designation_id = desr.designation_id
AND desr.relationship_type = 'Fund'
-- Join to funds
JOIN planning_center.giving_funds f
ON desr.relationship_id = f.fund_id
WHERE d.received_at >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY d.received_at DESC;
```
## Date-Based Queries
### Donations This Month
```sql theme={null}
-- All donations received this month
SELECT
COUNT(*) as donation_count,
SUM(amount_cents) / 100.0 as total_amount
FROM planning_center.giving_donations
WHERE received_at >= DATE_TRUNC('month', CURRENT_DATE)
AND received_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
AND refunded = false;
```
### Donations by Week
```sql theme={null}
-- Weekly giving for the last 8 weeks
SELECT
DATE_TRUNC('week', received_at) as week_starting,
COUNT(*) as donation_count,
SUM(amount_cents) / 100.0 as total_amount,
AVG(amount_cents) / 100.0 as average_donation
FROM planning_center.giving_donations
WHERE received_at >= CURRENT_DATE - INTERVAL '8 weeks'
AND refunded = false
GROUP BY DATE_TRUNC('week', received_at)
ORDER BY week_starting DESC;
```
### Year-to-Date Giving
```sql theme={null}
-- Total giving for the current year
SELECT
COUNT(DISTINCT donation_id) as total_donations,
SUM(amount_cents) / 100.0 as total_given,
AVG(amount_cents) / 100.0 as average_donation,
MAX(amount_cents) / 100.0 as largest_donation
FROM planning_center.giving_donations
WHERE received_at >= DATE_TRUNC('year', CURRENT_DATE)
AND received_at < DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 year'
AND refunded = false;
```
## Payment Methods
### Breakdown by Payment Method
```sql theme={null}
-- See how people prefer to give
SELECT
payment_method,
COUNT(*) as donation_count,
SUM(amount_cents) / 100.0 as total_amount,
AVG(amount_cents) / 100.0 as average_amount
FROM planning_center.giving_donations
WHERE received_at >= CURRENT_DATE - INTERVAL '90 days'
AND refunded = false
GROUP BY payment_method
ORDER BY total_amount DESC;
```
### Online vs Check Giving
```sql theme={null}
-- Compare electronic vs check donations
SELECT
CASE
WHEN payment_method = 'check' THEN 'Check'
WHEN payment_method IN ('card', 'ach', 'paypal') THEN 'Electronic'
ELSE 'Other'
END as payment_type,
COUNT(*) as donation_count,
SUM(amount_cents) / 100.0 as total_amount
FROM planning_center.giving_donations
WHERE received_at >= CURRENT_DATE - INTERVAL '30 days'
AND refunded = false
GROUP BY payment_type
ORDER BY total_amount DESC;
```
## Basic Aggregations
### Daily Giving Summary
```sql theme={null}
-- Daily totals for the last 14 days
SELECT
DATE(received_at) as donation_date,
COUNT(*) as num_donations,
COUNT(DISTINCT dr.relationship_id) as unique_donors,
SUM(d.amount_cents) / 100.0 as total_amount,
MIN(d.amount_cents) / 100.0 as smallest_donation,
MAX(d.amount_cents) / 100.0 as largest_donation
FROM planning_center.giving_donations d
LEFT JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
WHERE d.received_at >= CURRENT_DATE - INTERVAL '14 days'
AND d.refunded = false
GROUP BY DATE(d.received_at)
ORDER BY donation_date DESC;
```
### Top Donors This Month (Anonymous)
```sql theme={null}
-- Top 10 giving amounts this month (no names for privacy)
SELECT
dr.relationship_id as donor_id, -- Anonymous ID
COUNT(*) as donation_count,
SUM(d.amount_cents) / 100.0 as total_given
FROM planning_center.giving_donations d
JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
WHERE d.received_at >= DATE_TRUNC('month', CURRENT_DATE)
AND d.refunded = false
GROUP BY dr.relationship_id
ORDER BY total_given DESC
LIMIT 10;
```
### Fund Performance Summary
```sql theme={null}
-- How much has been given to each fund this year
SELECT
f.name as fund_name,
COUNT(DISTINCT des.designation_id) as num_designations,
SUM(des.amount_cents) / 100.0 as total_designated
FROM planning_center.giving_designations des
-- Join to funds via designation_relationships (type='Fund')
JOIN planning_center.giving_designations_relationships desr
ON des.designation_id = desr.designation_id
AND desr.relationship_type = 'Fund'
JOIN planning_center.giving_funds f
ON desr.relationship_id = f.fund_id
-- Get the donation date through donation_relationships (type='Designation')
JOIN planning_center.giving_donations_relationships dr_des
ON dr_des.relationship_id = des.designation_id
AND dr_des.relationship_type = 'Designation'
JOIN planning_center.giving_donations d
ON dr_des.donation_id = d.donation_id
WHERE d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
AND d.refunded = false
GROUP BY f.fund_id, f.name
ORDER BY total_designated DESC;
```
## Tips for Writing Queries
### 1. Always Convert Cents to Dollars
Use `amount_cents / 100.0` — the `.0` forces a decimal result instead of integer division.
```sql theme={null}
SELECT donation_id, amount_cents / 100.0 as amount
FROM planning_center.giving_donations;
```
### 2. Filter Out Refunded Donations
Add `WHERE refunded = false` unless you specifically want refunds included.
```sql theme={null}
SELECT donation_id, amount_cents / 100.0 as amount
FROM planning_center.giving_donations
WHERE refunded = false;
```
### 3. Use received\_at for Timing
* `received_at` = When the donation was actually received
* `created_at` = When it was entered into the system
* `completed_at` = When the transaction completed (may be NULL)
### 4. Handle NULL Values
Use `IS NOT NULL` to require a value and `COALESCE` to substitute a default.
```sql theme={null}
SELECT donation_id, COALESCE(amount_cents, 0) / 100.0 as amount
FROM planning_center.giving_donations
WHERE received_at IS NOT NULL;
```
### 5. Case-Insensitive Searches
Lowercase both sides so `Smith`, `SMITH`, and `smith` all match.
```sql theme={null}
SELECT person_id, first_name, last_name
FROM planning_center.giving_people
WHERE LOWER(last_name) LIKE '%smith%';
```
## Next Steps
Ready for more complex queries? Check out:
* [Advanced Queries](/planning-center/giving/advanced-queries) - Multi-table joins, subqueries, and complex aggregations
* [Reporting Examples](/planning-center/giving/reporting-examples) - Complete reports you can use immediately
## Common Issues & Solutions
### Issue: No results when joining tables
**Solution**: Check that you're using the correct relationship\_type in your join conditions.
### Issue: Amounts look too large
**Solution**: Remember to divide cents by 100.0 to get dollars.
### Issue: Missing recent donations
**Solution**: Check your WHERE clause - you might be filtering by created\_at instead of received\_at.
### Issue: Duplicate results
**Solution**: You might be missing a DISTINCT or GROUP BY clause, or joining incorrectly through relationship tables.
# Planning Center Giving Data Model
Source: https://docs.getparable.io/planning-center/giving/data-model
Complete reference for Planning Center Giving tables in Parable: donations, designations, funds, batches, pledges, and the relationships between them.
This document provides complete documentation of the Planning Center Giving data model in Parable, including all tables, fields, and relationships.
## Overview
The Giving module contains **19 entity tables** and **9 relationship tables** supporting donation processing, fund management, pledges, recurring donations, and financial reporting.
## Visual Data Model
The diagram below shows the core entities and their relationships in the Giving module. Use it as a visual reference while exploring the detailed table definitions below.
### Core Entity Relationships
[Open diagram in new tab →](/diagrams/planning-center/giving-data-model-01.svg)
### Key Relationships Explained
**Donation Flow:**
1. A `DONATION` is created with amount and payment details
2. `DESIGNATION`(s) split the donation across one or more `FUND`s
3. Donations are grouped into `BATCH`es for processing
4. Batches can be organized into `BATCH_GROUP`s
**Generic Relationship Pattern:**
* Donor information stored via `giving_donations_relationships` (relationship\_type: `'Person'`)
* Campus association via `giving_donations_relationships` (relationship\_type: `'Campus'`)
* Designation links via `giving_donations_relationships` (relationship\_type: `'Designation'`)
* Fund links via `giving_designations_relationships` (relationship\_type: `'Fund'`)
* Payment details via `giving_donations_relationships` (relationship\_type: `'PaymentSource'`)
**Pledge System:**
* `PLEDGE_CAMPAIGN` defines fundraising campaigns
* `PLEDGE`s are commitments made during campaigns
* Person relationship tracked via `giving_pledges_relationships`
**Recurring Donations:**
* `RECURRING_DONATION` defines the schedule and total amount
* `RECURRING_DONATION_DESIGNATION`s split recurring amounts across funds
* Actual donations created by Planning Center on schedule
**Refund Handling:**
* `REFUND` represents the refund transaction
* `DESIGNATION_REFUND`s track which fund allocations were refunded
* Maintains audit trail of original donation and refund
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Giving module are in the `planning_center` schema. You MUST prefix all table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.giving_donations`
❌ INCORRECT: `SELECT * FROM giving_donations`
### Row Level Security (RLS)
This database uses Row Level Security (RLS) to automatically filter data based on:
* **tenant\_organization\_id**: You only see data for your current organization
* **system\_status**: You only see 'active' records by default
**DO NOT add these filters to your WHERE clause** - they are applied automatically:
* ❌ `WHERE tenant_organization_id = 1` (unnecessary)
* ❌ `WHERE system_status = 'active'` (unnecessary)
The RLS policies ensure you only access data you're authorized to see, making these filters redundant and potentially causing performance issues.
## Core Tables Overview
### Primary Entity Tables
* `giving_donations` - Individual donation transactions
* `giving_people` - Donor profiles
* `giving_funds` - Fund definitions for designated giving
* `giving_designations` - Donation allocations to funds
* `giving_batches` - Groups of donations processed together
* `giving_pledges` - Pledge commitments
* `giving_pledge_campaigns` - Campaign definitions
* `giving_recurring_donations` - Automated recurring giving
### Financial Processing Tables
* `giving_batch_groups` - Groups of donation batches
* `giving_payment_methods` - Payment method details
* `giving_payment_sources` - Sources of payments
* `giving_recurring_donation_designations` - Fund allocations for recurring
* `giving_refunds` - Refund transactions
* `giving_designation_refunds` - Refunds for specific designations
* `giving_in_kind_donations` - Non-cash (in-kind) donation records
### Reference Tables
* `giving_campuses` - Physical locations/campuses
* `giving_organizations` - Organization settings
* `giving_labels` - Categorization tags
* `giving_notes` - Text notes
### Relationship Tables
* `giving_donations_relationships` - Links donations to related entities
* `giving_designations_relationships` - Links designations to donations
* `giving_pledges_relationships` - Links pledges to people
* `giving_people_relationships` - Links people to other entities
* `giving_batches_relationships` - Links batches to related entities
* `giving_refunds_relationships` - Links refunds to donations
* `giving_in_kind_donations_relationships` - Links in-kind donations to related entities
* `giving_recurring_donation_relationships` - Links recurring donations to people and funds
* `giving_recurring_donation_designation_relationships` - Links recurring donation designations to funds (`relationship_type = 'Fund'`)
## Table Definitions
### giving\_donations
Individual donation transactions from donors.
| Column | Type | Description |
| ------------------------ | ------------ | ---------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `donation_id` | VARCHAR(64) | Planning Center donation ID |
| `amount_cents` | INTEGER | Donation amount in cents |
| `amount_currency` | VARCHAR(10) | Currency code (USD, CAD, etc.) |
| `completed_at` | TIMESTAMP | When transaction completed |
| `created_at` | TIMESTAMP | When donation was created |
| `fee_cents` | INTEGER | Processing fee in cents |
| `fee_covered` | BOOLEAN | Whether donor covered the fee |
| `fee_currency` | VARCHAR(10) | Fee currency code |
| `payment_brand` | VARCHAR(255) | Card brand (Visa, Mastercard, etc.) |
| `payment_check_dated_at` | DATE | Date on check |
| `payment_check_number` | INTEGER | Check number |
| `payment_last4` | VARCHAR(4) | Last 4 digits of card |
| `payment_method` | VARCHAR(255) | Payment type (cash, check, card, ach) |
| `payment_method_sub` | VARCHAR(255) | Payment subtype details |
| `payment_status` | VARCHAR(255) | Transaction status |
| `received_at` | TIMESTAMP | When donation was received |
| `refundable` | BOOLEAN | Whether donation can be refunded |
| `refunded` | BOOLEAN | Whether donation has been refunded |
| `updated_at` | TIMESTAMP | Last update time |
| `memo` | TEXT | Donation memo/note |
| `payment_channel` | VARCHAR(50) | Channel used for payment |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status: 'active', 'transferring', 'stale' |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Note**: Related entities (person, batch, campus, fund, recurring donation, refund) are linked via `giving_donations_relationships` using `relationship_type` values such as `'Person'`, `'Batch'`, `'Campus'`, `'Designation'`, `'RecurringDonation'`, `'Refund'`, etc.
### giving\_people
Donor profiles and information.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_id` | VARCHAR(64) | Planning Center person ID |
| `donor_number` | INTEGER | Unique donor identifier |
| `first_name` | VARCHAR(255) | Donor's first name |
| `last_name` | VARCHAR(255) | Donor's last name |
| `permissions` | VARCHAR(255) | Giving permissions |
| `first_donated_at` | TIMESTAMP | Date of first donation |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_funds
Fund definitions for designated giving.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `fund_id` | VARCHAR(64) | Planning Center fund ID |
| `color` | VARCHAR(64) | Display color for fund |
| `color_identifier` | INTEGER | Numeric color identifier |
| `created_at` | TIMESTAMP | When fund was created |
| `is_default` | BOOLEAN | Whether this is the default/general fund |
| `deletable` | BOOLEAN | Whether fund can be deleted |
| `description` | TEXT | Fund description |
| `ledger_code` | VARCHAR(64) | Accounting ledger code |
| `name` | VARCHAR(255) | Fund name |
| `slug` | TEXT | URL-friendly identifier for the fund |
| `updated_at` | TIMESTAMP | Last update time |
| `visibility` | VARCHAR(64) | Who can see this fund (everywhere, admin\_only, etc.) |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_designations
How donations are allocated to specific funds.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `designation_id` | VARCHAR(64) | Planning Center designation ID |
| `amount_cents` | INTEGER | Amount designated in cents |
| `amount_currency` | VARCHAR(10) | Currency code |
| `fee_cents` | INTEGER | Processing fee in cents |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_batches
Groups of donations processed together.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `batch_id` | VARCHAR(64) | Planning Center batch ID |
| `committed_at` | TIMESTAMP | When batch was committed |
| `created_at` | TIMESTAMP | When batch was created |
| `description` | TEXT | Batch description |
| `donations_count` | INTEGER | Number of donations in this batch |
| `status` | VARCHAR(50) | Batch status (in\_progress, committed) |
| `total_cents` | INTEGER | Total amount in cents |
| `total_currency` | VARCHAR(50) | Currency code |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_pledges
Pledge commitments for campaigns.
| Column | Type | Description |
| --------------------------------- | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `pledge_id` | VARCHAR(64) | Planning Center pledge ID |
| `amount_cents` | INTEGER | Pledged amount in cents |
| `amount_currency` | VARCHAR(10) | Currency code |
| `created_at` | TIMESTAMP | When pledge was made |
| `donated_total_cents` | INTEGER | Total amount donated toward this pledge |
| `joint_giver_amount_cents` | INTEGER | Amount pledged by joint giver |
| `joint_giver_donated_total_cents` | INTEGER | Total amount donated by joint giver |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Note**: Pledges connect to pledge campaigns and people via `giving_pledges_relationships` using `relationship_type` values `'PledgeCampaign'` and `'Person'`. There is no direct `pledge_campaign_id` or `received_cents` column.
### giving\_pledge\_campaigns
Campaign definitions for pledge drives.
| Column | Type | Description |
| ----------------------------------------- | ------------ | ---------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `pledge_campaign_id` | VARCHAR(64) | Planning Center campaign ID |
| `created_at` | TIMESTAMP | When campaign was created |
| `description` | TEXT | Campaign description |
| `ends_at` | TIMESTAMP | Campaign end date |
| `goal_cents` | INTEGER | Campaign goal in cents |
| `goal_currency` | VARCHAR(255) | Goal currency |
| `name` | TEXT | Campaign name |
| `received_total_from_pledges_cents` | INTEGER | Total received from pledged donations (use this, not `received_total_cents`) |
| `received_total_outside_of_pledges_cents` | INTEGER | Total received from non-pledged donations |
| `show_goal_in_church_center` | BOOLEAN | Public visibility of goal |
| `starts_at` | TIMESTAMP | Campaign start date |
| `updated_at` | TIMESTAMP | Last update |
| `fund_id` | VARCHAR(64) | Associated fund |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_recurring\_donations
Automated recurring giving setups.
| Column | Type | Description |
| --------------------------- | ----------- | -------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `recurring_donation_id` | VARCHAR(64) | Planning Center recurring ID |
| `created_at` | TIMESTAMP | When setup was created |
| `updated_at` | TIMESTAMP | Last update |
| `release_hold_at` | TIMESTAMP | When hold releases |
| `amount_cents` | INTEGER | Recurring amount in cents |
| `amount_currency` | VARCHAR(50) | Currency code |
| `status` | VARCHAR(50) | Status (active, indefinite\_hold, temporary\_hold) |
| `last_donation_received_at` | TIMESTAMP | Most recent successful donation |
| `next_occurrence` | TIMESTAMP | Next scheduled donation |
| `schedule` | JSONB | Schedule configuration |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_batch\_groups
Groups of donation batches for organizational purposes.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `batch_group_id` | VARCHAR(64) | Planning Center batch group ID |
| `committed` | BOOLEAN | Whether batch group is committed |
| `created_at` | TIMESTAMP | When created |
| `description` | TEXT | Human-readable description |
| `status` | VARCHAR(50) | Batch group status |
| `total_cents` | INTEGER | Total amount in cents |
| `total_currency` | VARCHAR(50) | Currency code |
| `updated_at` | TIMESTAMP | Last update |
| `owner_id` | VARCHAR(64) | Person who owns this batch group |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_payment\_methods
Payment method details for recurring donations.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `payment_method_id` | VARCHAR(64) | Planning Center payment method ID |
| `recurring_donation_id` | VARCHAR(64) | Associated recurring donation ID |
| `brand` | VARCHAR(255) | Card brand |
| `created_at` | TIMESTAMP | When created |
| `expiration` | VARCHAR(10) | Expiration date |
| `last4` | VARCHAR(4) | Last 4 digits |
| `method_subtype` | VARCHAR(255) | Payment method subtype |
| `method_type` | VARCHAR(255) | Payment method type |
| `updated_at` | TIMESTAMP | Last update |
| `verified` | BOOLEAN | Whether verified |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_payment\_sources
Sources of payments (broader category than payment methods).
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `payment_source_id` | VARCHAR(64) | Planning Center payment source ID |
| `created_at` | TIMESTAMP | When created |
| `name` | VARCHAR(255) | Display name |
| `payment_source_type` | VARCHAR(255) | Type of payment source |
| `status` | VARCHAR(50) | Payment source status |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_refunds
Refund transactions for donations.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `refund_id` | VARCHAR(64) | Planning Center refund ID |
| `amount_cents` | INTEGER | Refund amount in cents |
| `amount_currency` | VARCHAR(10) | Currency code |
| `created_at` | TIMESTAMP | When created |
| `fee_cents` | INTEGER | Refund processing fee in cents |
| `fee_currency` | VARCHAR(10) | Fee currency code |
| `refunded_at` | TIMESTAMP | When refund was processed |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_campuses
Physical locations/campuses for the organization.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `campus_id` | VARCHAR(64) | Planning Center campus ID |
| `name` | TEXT | Campus name |
| `street_line_1` | VARCHAR(255) | Street address line 1 |
| `street_line_2` | VARCHAR(255) | Street address line 2 |
| `city` | VARCHAR(255) | City |
| `state` | VARCHAR(255) | State |
| `zip` | VARCHAR(255) | ZIP code |
| `location` | VARCHAR(255) | Location description |
| `street` | VARCHAR(255) | Street name |
| `line_1` | VARCHAR(255) | Address line 1 |
| `line_2` | VARCHAR(255) | Address line 2 |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_organizations
Organization-level settings and information for giving.
| Column | Type | Description |
| ------------------------ | ------------ | ---------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `organization_id` | VARCHAR(64) | Planning Center organization ID |
| `name` | VARCHAR(255) | Organization name |
| `time_zone` | VARCHAR(50) | Organization time zone |
| `text2give_enabled` | BOOLEAN | Whether Text-to-Give is enabled for the organization |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_labels
Categorization tags for giving-related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `label_id` | VARCHAR(64) | Planning Center label ID |
| `slug` | TEXT | URL-friendly identifier |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_notes
Text notes attached to giving-related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `note_id` | VARCHAR(64) | Planning Center note ID |
| `body` | TEXT | Note content |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_recurring\_donation\_designations
Fund allocations for recurring donations.
| Column | Type | Description |
| ----------------------------------- | ----------- | ------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `recurring_donation_designation_id` | VARCHAR(64) | Planning Center recurring donation designation ID |
| `amount_cents` | INTEGER | Amount designated in cents |
| `amount_currency` | VARCHAR(10) | Currency code |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_designation\_refunds
Refunds for specific designations within donations.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `designation_refund_id` | VARCHAR(64) | Planning Center designation refund ID |
| `donation_id` | VARCHAR(64) | Associated donation ID |
| `amount_cents` | INTEGER | Refund amount in cents |
| `amount_currency` | VARCHAR(10) | Currency code |
| `designation_id` | VARCHAR(64) | Associated designation ID |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_in\_kind\_donations
Non-cash (in-kind) donation records such as property, goods, or services.
| Column | Type | Description |
| ----------------------------- | ----------- | -------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `in_kind_donation_id` | VARCHAR(64) | Planning Center in-kind donation ID |
| `acknowledgment_last_sent_at` | TIMESTAMP | When the last acknowledgment was sent to the donor |
| `created_at` | TIMESTAMP | When the in-kind donation was created |
| `description` | TEXT | Description of the donated item or service |
| `exchange_details` | TEXT | Details about any exchange arrangement |
| `fair_market_value_cents` | INTEGER | Fair market value of the donation in cents |
| `fair_market_value_currency` | VARCHAR(10) | Currency code for fair market value |
| `internal_notes` | TEXT | Internal staff notes about the donation |
| `received_on` | DATE | Date the in-kind donation was received |
| `updated_at` | TIMESTAMP | Last update time |
| `valuation_details` | TEXT | Details about how the value was determined |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Note**: Related entities (person, fund, campus) are linked via `giving_in_kind_donations_relationships` using the appropriate `relationship_type` values.
## Relationship Tables
### giving\_donations\_relationships
Links donations to related entities beyond the direct ID columns.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `donation_id` | VARCHAR(64) | Parent donation ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_designations\_relationships
Links designations to donations and other entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `designation_id` | VARCHAR(64) | Parent designation ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_pledges\_relationships
Links pledges to people and campaigns.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `pledge_id` | VARCHAR(64) | Parent pledge ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_people\_relationships
Links people to other entities beyond the direct ID columns.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_id` | VARCHAR(64) | Parent person ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_batches\_relationships
Links batches to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `batch_id` | VARCHAR(64) | Parent batch ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_refunds\_relationships
Links refunds to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `refund_id` | VARCHAR(64) | Parent refund ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### giving\_in\_kind\_donations\_relationships
Links in-kind donations to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `in_kind_donation_id` | VARCHAR(64) | Parent in-kind donation ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
## System Fields
All tables include these system fields for data management:
* `tenant_organization_id` - Multi-tenant organization identifier
* `system_status` - Data lifecycle status:
* `transferring` - Being imported from Planning Center
* `active` - Current active data
* `stale` - Marked for removal
* `system_created_at` - When record was created in Parable
* `system_updated_at` - When record was last updated in Parable
## Common Query Patterns
### Getting Donations with Donor Information
```sql theme={null}
-- CORRECT: Schema prefix included, no manual RLS filters
-- giving_donations has no direct person_id column; join via giving_donations_relationships
SELECT
d.donation_id,
d.amount_cents / 100.0 as amount,
d.amount_currency,
p.first_name,
p.last_name,
p.donor_number
FROM planning_center.giving_donations d
LEFT JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
LEFT JOIN planning_center.giving_people p
ON dr.relationship_id = p.person_id
WHERE d.received_at >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY d.received_at DESC;
```
### Donation Designations by Fund
```sql theme={null}
-- giving_designations has no direct fund_id or donation_id columns;
-- use the relationship tables to join funds → designations → donations
SELECT
f.name as fund_name,
COUNT(DISTINCT des.designation_id) as designation_count,
SUM(des.amount_cents) / 100.0 as total_amount
FROM planning_center.giving_funds f
-- Join funds to designations via designation_relationships (type='Fund')
JOIN planning_center.giving_designations_relationships desr
ON desr.relationship_id = f.fund_id
AND desr.relationship_type = 'Fund'
JOIN planning_center.giving_designations des
ON des.designation_id = desr.designation_id
-- Join designations to donations via donation_relationships (type='Designation')
JOIN planning_center.giving_donations_relationships dr_des
ON dr_des.relationship_id = des.designation_id
AND dr_des.relationship_type = 'Designation'
JOIN planning_center.giving_donations d
ON d.donation_id = dr_des.donation_id
WHERE d.received_at >= DATE_TRUNC('month', CURRENT_DATE)
AND d.refunded = false
GROUP BY f.fund_id, f.name
ORDER BY total_amount DESC;
```
### Batch Summary Report
```sql theme={null}
-- giving_donations has no direct batch_id column;
-- join via giving_donations_relationships (relationship_type='Batch')
SELECT
b.batch_id,
b.description,
b.committed_at,
b.total_cents / 100.0 as batch_total,
b.donations_count,
COUNT(d.donation_id) as calculated_donation_count,
SUM(d.amount_cents) / 100.0 as calculated_total
FROM planning_center.giving_batches b
LEFT JOIN planning_center.giving_donations_relationships dr
ON dr.relationship_id = b.batch_id
AND dr.relationship_type = 'Batch'
LEFT JOIN planning_center.giving_donations d
ON d.donation_id = dr.donation_id
WHERE b.status = 'committed'
GROUP BY b.batch_id, b.description, b.committed_at, b.total_cents, b.donations_count
ORDER BY b.committed_at DESC;
```
### Recurring Donation Status
```sql theme={null}
-- giving_donations has no direct recurring_donation_id or person_id columns;
-- find person via donation_relationships using RecurringDonation→Person chain
SELECT DISTINCT ON (rd.recurring_donation_id)
rd.recurring_donation_id,
rd.amount_cents / 100.0 as amount,
rd.status,
rd.last_donation_received_at,
rd.next_occurrence,
p.first_name,
p.last_name
FROM planning_center.giving_recurring_donations rd
-- Find donations linked to this recurring donation
LEFT JOIN planning_center.giving_donations_relationships dr_rd
ON dr_rd.relationship_id = rd.recurring_donation_id
AND dr_rd.relationship_type = 'RecurringDonation'
-- Get the person from that same donation
LEFT JOIN planning_center.giving_donations_relationships dr_p
ON dr_p.donation_id = dr_rd.donation_id
AND dr_p.relationship_type = 'Person'
LEFT JOIN planning_center.giving_people p
ON p.person_id = dr_p.relationship_id
WHERE rd.status = 'active'
ORDER BY rd.recurring_donation_id, rd.next_occurrence;
```
## Data Integrity Rules
1. **Schema Qualification**: Always use `planning_center.` prefix for all table references
2. **Row Level Security**: RLS automatically handles multi-tenancy and status filtering - do not add manual filters
3. **Monetary Values**: All amounts are stored in cents - divide by 100.0 for display
4. **Refunded Donations**: Filter `refunded = true` when needed for financial reports
5. **Relationship Tables**: All entity-to-entity links (donations→person, donations→batch, designations→fund, pledges→campaign, etc.) go through relationship tables — there are no direct FK columns on these tables
## Common Mistakes to Avoid
1. **Missing Schema Prefix**
* ❌ `FROM giving_donations`
* ✅ `FROM planning_center.giving_donations`
2. **Adding Redundant RLS Filters**
* ❌ `WHERE tenant_organization_id = 1 AND system_status = 'active'`
* ✅ Trust RLS to handle this automatically
3. **Joining Without Schema**
* ❌ `JOIN giving_people p ON ...`
* ✅ `JOIN planning_center.giving_people p ON ...`
4. **Forgetting Currency Conversion**
* ❌ `SELECT amount_cents as amount` (displays cents)
* ✅ `SELECT amount_cents / 100.0 as amount` (displays dollars)
## Performance Considerations
1. **Indexes**: All tables have optimized indexes on:
* Primary keys and entity IDs
* Join columns and foreign keys
* Date columns for time-based queries
2. **Query Optimization**:
* Always use the `planning_center.` schema prefix
* RLS handles tenant and status filtering automatically
* Filter refunded donations when needed
* Consider CTEs for complex aggregations
* Join through the `*_relationships` tables — entity tables carry no foreign-key columns
## Data Types and Conventions
### Monetary Values
* All amounts stored in **cents** (INTEGER)
* Divide by 100.0 for dollar amounts
* Currency codes follow ISO 4217 (USD, CAD, EUR, etc.)
### Dates and Times
* TIMESTAMP fields represent UTC time
* DATE fields for date-only values (no time component)
* All times stored without timezone info (implicit UTC)
### Payment Methods
* `payment_method` - Type (cash, check, card, ach)
* `payment_method_sub` - Subtype details
* `payment_status` - Transaction status
### Boolean Values
* TRUE/FALSE for PostgreSQL boolean type
* No NULL booleans - default to FALSE where appropriate
## Next Steps
* Start with [Basic Queries](/planning-center/giving/basic-queries) for simple examples
* Progress to [Advanced Queries](/planning-center/giving/advanced-queries) for complex analysis
* Use [Reporting Examples](/planning-center/giving/reporting-examples) for production reports
* Return to [Overview](/planning-center/giving/overview) for overview
# Planning Center Giving SQL Queries
Source: https://docs.getparable.io/planning-center/giving/overview
Query Planning Center Giving data with SQL to track donation trends, analyze fund performance, and understand your congregation's generosity.
## Transform Your Giving Data Into Ministry Impact
Your church's generosity tells a powerful story. With Parable's SQL access to Planning Center Giving data, you can uncover insights that help you understand your congregation's generosity and make data-driven ministry decisions.
## Quick Start
Ready to start querying your giving data? Here's your first query to see recent donations:
```sql theme={null}
-- See your most recent 10 donations
SELECT
d.donation_id,
d.amount_cents / 100.0 as amount, -- Convert cents to dollars
d.amount_currency,
d.payment_method,
d.received_at,
d.created_at
FROM planning_center.giving_donations d
WHERE d.received_at IS NOT NULL
ORDER BY d.received_at DESC
LIMIT 10;
```
## What You Can Do With Giving Queries
### 📊 Track Giving Trends
* Monitor weekly, monthly, and annual giving patterns
* Identify seasonal trends in your congregation's generosity
* Compare year-over-year growth
### 👥 Understand Your Donors
* Segment donors by giving frequency and amount
* Identify first-time givers for follow-up
* Track donor retention and engagement
### 💰 Analyze Fund Performance
* See which funds are meeting their goals
* Track designated giving vs general fund
* Monitor campaign progress with nightly updates
### 📈 Generate Ministry Reports
* Create custom giving statements
* Build dashboard metrics for leadership
* Export data for board meetings and annual reports
## Available Tables
Your Planning Center Giving data is organized into these main tables:
| Table | What It Contains | Key Use Cases |
| ---------------------------- | -------------------------------- | --------------------------------------------- |
| `giving_donations` | Individual donation transactions | Transaction history, payment methods, amounts |
| `giving_people` | Donor information | Donor profiles, giving units, donor numbers |
| `giving_funds` | Fund definitions | Fund names, descriptions, default settings |
| `giving_designations` | How donations are allocated | Fund allocation within donations |
| `giving_batches` | Donation batches | Batch processing, deposit tracking |
| `giving_pledges` | Pledge commitments | Capital campaigns, pledge tracking |
| `giving_recurring_donations` | Recurring giving setups | Subscription giving analysis |
## Understanding Relationships
Parable stores Planning Center relationships in a special way to maintain data integrity. Instead of foreign keys directly in tables, relationships are stored in separate relationship tables:
* `giving_donations_relationships` - Links donations to people, batches, campuses
* `giving_designations_relationships` - Links designations to donations
* `giving_people_relationships` - Links people to campuses and other entities
Don't worry - we'll show you exactly how to join these tables in our examples!
## Next Steps
📚 **New to SQL?** Start with [Basic Queries](/planning-center/giving/basic-queries) for simple, powerful queries you can use today.
🚀 **Ready for More?** Check out [Advanced Queries](/planning-center/giving/advanced-queries) for complex analysis and reporting.
📊 **Need Reports?** See [Reporting Examples](/planning-center/giving/reporting-examples) for complete, production-ready reports.
🔍 **Want Details?** Review the [Data Model](/planning-center/giving/data-model) for complete table documentation.
## Common Questions
### Why are amounts stored in cents?
Planning Center stores all monetary values in cents to avoid floating-point precision issues. Simply divide by 100 to get dollar amounts:
```sql theme={null}
SELECT donation_id, amount_cents / 100.0 as amount_dollars
FROM planning_center.giving_donations;
```
### How do I filter by date?
Use the `received_at` field for when donations were received, or `created_at` for when they were entered:
```sql theme={null}
SELECT donation_id, received_at, amount_cents / 100.0 as amount_dollars
FROM planning_center.giving_donations
WHERE received_at >= DATE_TRUNC('year', CURRENT_DATE)
AND received_at < DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 year';
```
### What's the difference between a donation and a designation?
* A **donation** is the complete transaction from a donor
* A **designation** shows how that donation is split between funds
* One donation can have multiple designations
### How do I join to get donor names?
Join through the relationship table:
```sql theme={null}
SELECT
d.amount_cents / 100.0 as amount,
p.first_name,
p.last_name
FROM planning_center.giving_donations d
JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_people p
ON dr.relationship_id = p.person_id
```
## Getting Help
* 🐛 Found an issue? Report it at [github.com/getparable/parable-api/issues](https://github.com/getparable/parable-api/issues)
* 📖 Need more examples? Check our other query guides in this folder
* 💬 Have questions? Reach out to your Parable support team
***
*Remember: Your data tells a story. Let us help you discover it.*
# Planning Center Giving Report Examples
Source: https://docs.getparable.io/planning-center/giving/reporting-examples
Production-ready giving reports for board meetings and donor statements: fund summaries, donor totals, and trends you can run as-is.
Production-ready SQL reports you can use immediately for board meetings, giving statements, and ministry decisions.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Giving module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your reports.
✅ CORRECT: `SELECT * FROM planning_center.giving_donations`
❌ INCORRECT: `SELECT * FROM giving_donations`
### Row Level Security (RLS)
Row Level Security automatically scopes results by:
* **tenant\_organization\_id** – only data from your organization
* **system\_status** – active records returned by default
**Do not add these filters manually**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus your WHERE clauses on giving-specific dimensions (date ranges, refunded status, funds) while RLS manages tenancy and system status.
## Table of Contents
* [Executive Dashboard Reports](#executive-dashboard-reports)
* [Donor Giving Statements](#donor-giving-statements)
* [Fund Reports](#fund-reports)
* [Campaign Reports](#campaign-reports)
* [Tax and Compliance Reports](#tax-and-compliance-reports)
* [Trend Analysis Reports](#trend-analysis-reports)
## Executive Dashboard Reports
### Monthly Executive Summary
A comprehensive overview for leadership meetings:
```sql theme={null}
-- Executive Monthly Giving Summary
WITH current_month AS (
SELECT
DATE_TRUNC('month', CURRENT_DATE) as month_start,
DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month' - INTERVAL '1 day' as month_end
),
last_month AS (
SELECT
DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month') as month_start,
DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 day' as month_end
),
current_month_stats AS (
SELECT
COUNT(DISTINCT d.donation_id) as donation_count,
COUNT(DISTINCT dr.relationship_id) as unique_donors,
SUM(d.amount_cents) / 100.0 as total_amount,
AVG(d.amount_cents) / 100.0 as avg_donation,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY d.amount_cents) / 100.0 as median_donation
FROM planning_center.giving_donations d
LEFT JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
CROSS JOIN current_month cm
WHERE d.received_at >= cm.month_start
AND d.received_at <= cm.month_end
AND d.refunded = false
),
last_month_stats AS (
SELECT
COUNT(DISTINCT d.donation_id) as donation_count,
COUNT(DISTINCT dr.relationship_id) as unique_donors,
SUM(d.amount_cents) / 100.0 as total_amount
FROM planning_center.giving_donations d
LEFT JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
CROSS JOIN last_month lm
WHERE d.received_at >= lm.month_start
AND d.received_at <= lm.month_end
AND d.refunded = false
),
ytd_stats AS (
SELECT
SUM(amount_cents) / 100.0 as ytd_total,
COUNT(DISTINCT dr.relationship_id) as ytd_unique_donors
FROM planning_center.giving_donations d
LEFT JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
WHERE d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
AND d.refunded = false
)
SELECT
TO_CHAR(CURRENT_DATE, 'FMMonth YYYY') as report_month,
cms.donation_count as donations_this_month,
cms.unique_donors as unique_donors_this_month,
cms.total_amount as total_this_month,
cms.avg_donation as avg_donation_this_month,
cms.median_donation as median_donation_this_month,
ROUND(((cms.total_amount - lms.total_amount) / NULLIF(lms.total_amount, 0)) * 100, 2) as month_over_month_change_pct,
ROUND(((cms.unique_donors - lms.unique_donors) / NULLIF(lms.unique_donors::numeric, 0)) * 100, 2) as donor_change_pct,
ytd.ytd_total as year_to_date_total,
ytd.ytd_unique_donors as year_to_date_unique_donors
FROM current_month_stats cms
CROSS JOIN last_month_stats lms
CROSS JOIN ytd_stats ytd;
```
### Weekly Giving Snapshot
Quick weekly overview for staff meetings:
```sql theme={null}
-- Weekly Giving Snapshot with Comparisons
WITH weeks AS (
SELECT
DATE_TRUNC('week', received_at) as week_start,
COUNT(*) as donation_count,
COUNT(DISTINCT dr.relationship_id) as unique_donors,
SUM(d.amount_cents) / 100.0 as total_amount,
SUM(CASE WHEN d.payment_method = 'cash' THEN d.amount_cents ELSE 0 END) / 100.0 as cash_total,
SUM(CASE WHEN d.payment_method = 'check' THEN d.amount_cents ELSE 0 END) / 100.0 as check_total,
SUM(CASE WHEN d.payment_method IN ('card', 'ach') THEN d.amount_cents ELSE 0 END) / 100.0 as electronic_total
FROM planning_center.giving_donations d
LEFT JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
WHERE d.received_at >= CURRENT_DATE - INTERVAL '8 weeks'
AND d.refunded = false
GROUP BY DATE_TRUNC('week', received_at)
)
SELECT
TO_CHAR(week_start, 'MM/DD/YYYY') as week_beginning,
donation_count,
unique_donors,
total_amount,
cash_total,
check_total,
electronic_total,
LAG(total_amount, 1) OVER (ORDER BY week_start) as previous_week,
ROUND(((total_amount - LAG(total_amount, 1) OVER (ORDER BY week_start)) /
NULLIF(LAG(total_amount, 1) OVER (ORDER BY week_start), 0)) * 100, 2) as week_over_week_change_pct
FROM weeks
ORDER BY week_start DESC;
```
## Donor Giving Statements
### Annual Giving Statement for Individual Donor
Complete giving history for tax purposes:
```sql theme={null}
-- Annual Giving Statement for a Specific Donor
-- Replace 'DONOR_ID_HERE' with actual person_id
WITH donor_info AS (
SELECT
person_id,
first_name,
last_name,
donor_number
FROM planning_center.giving_people
WHERE person_id = 'DONOR_ID_HERE' -- Replace with actual ID
),
donation_details AS (
SELECT
d.donation_id,
d.received_at,
d.amount_cents / 100.0 as amount,
d.payment_method,
d.payment_check_number,
d.refunded,
STRING_AGG(
f.name || ': $' || (des.amount_cents / 100.0)::text,
', '
ORDER BY des.amount_cents DESC
) as fund_breakdown
FROM planning_center.giving_donations d
JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
JOIN donor_info di ON dr.relationship_id = di.person_id
-- Join to designations via donation_relationships (type='Designation')
LEFT JOIN planning_center.giving_donations_relationships dr_des
ON d.donation_id = dr_des.donation_id
AND dr_des.relationship_type = 'Designation'
LEFT JOIN planning_center.giving_designations des
ON dr_des.relationship_id = des.designation_id
-- Join to funds via designation_relationships (type='Fund')
LEFT JOIN planning_center.giving_designations_relationships desr
ON des.designation_id = desr.designation_id
AND desr.relationship_type = 'Fund'
LEFT JOIN planning_center.giving_funds f
ON desr.relationship_id = f.fund_id
WHERE EXTRACT(YEAR FROM d.received_at) = EXTRACT(YEAR FROM CURRENT_DATE)
GROUP BY d.donation_id, d.received_at, d.amount_cents,
d.payment_method, d.payment_check_number, d.refunded
),
summary AS (
SELECT
COUNT(*) FILTER (WHERE NOT refunded) as total_donations,
SUM(amount) FILTER (WHERE NOT refunded) as total_given,
SUM(amount) FILTER (WHERE refunded) as total_refunded
FROM donation_details
)
SELECT
di.first_name || ' ' || di.last_name as donor_name,
di.donor_number,
EXTRACT(YEAR FROM CURRENT_DATE) as tax_year,
dd.received_at as donation_date,
dd.amount,
dd.payment_method,
dd.payment_check_number as check_number,
dd.fund_breakdown,
CASE WHEN dd.refunded THEN 'REFUNDED' ELSE '' END as status,
s.total_donations,
s.total_given as year_total,
s.total_refunded as year_refunded,
s.total_given - COALESCE(s.total_refunded, 0) as net_contributions
FROM donation_details dd
CROSS JOIN donor_info di
CROSS JOIN summary s
ORDER BY dd.received_at;
```
### Quarterly Giving Statements for All Donors
Bulk generation of quarterly statements:
```sql theme={null}
-- Quarterly Giving Statements for All Active Donors
WITH quarter_dates AS (
SELECT
DATE_TRUNC('quarter', CURRENT_DATE) as quarter_start,
DATE_TRUNC('quarter', CURRENT_DATE) + INTERVAL '3 months' - INTERVAL '1 day' as quarter_end,
'Q' || EXTRACT(QUARTER FROM CURRENT_DATE) || ' ' ||
EXTRACT(YEAR FROM CURRENT_DATE) as quarter_label
),
donor_quarterly_summary AS (
SELECT
p.person_id,
p.first_name,
p.last_name,
p.donor_number,
COUNT(d.donation_id) as donation_count,
SUM(d.amount_cents) / 100.0 as total_given,
MIN(d.received_at) as first_donation,
MAX(d.received_at) as last_donation,
STRING_AGG(
DISTINCT f.name,
', '
ORDER BY f.name
) as funds_supported
FROM planning_center.giving_people p
JOIN planning_center.giving_donations_relationships dr
ON p.person_id = dr.relationship_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d
ON dr.donation_id = d.donation_id
-- Join to designations via donation_relationships (type='Designation')
JOIN planning_center.giving_donations_relationships dr_des
ON d.donation_id = dr_des.donation_id
AND dr_des.relationship_type = 'Designation'
JOIN planning_center.giving_designations des
ON dr_des.relationship_id = des.designation_id
-- Join to funds via designation_relationships (type='Fund')
JOIN planning_center.giving_designations_relationships desr
ON des.designation_id = desr.designation_id
AND desr.relationship_type = 'Fund'
JOIN planning_center.giving_funds f
ON desr.relationship_id = f.fund_id
CROSS JOIN quarter_dates qd
WHERE d.received_at >= qd.quarter_start
AND d.received_at <= qd.quarter_end
AND d.refunded = false
GROUP BY p.person_id, p.first_name, p.last_name, p.donor_number
)
SELECT
qd.quarter_label,
dqs.donor_number,
dqs.first_name || ' ' || dqs.last_name as donor_name,
dqs.donation_count,
dqs.total_given,
dqs.funds_supported,
TO_CHAR(dqs.first_donation, 'MM/DD/YYYY') as first_donation_date,
TO_CHAR(dqs.last_donation, 'MM/DD/YYYY') as last_donation_date
FROM donor_quarterly_summary dqs
CROSS JOIN quarter_dates qd
ORDER BY dqs.total_given DESC;
```
## Fund Reports
### Fund Performance Report
Comprehensive fund analysis with goals:
```sql theme={null}
-- Fund Performance Against Goals
WITH fund_goals AS (
-- Define your fund goals here (could come from another table)
SELECT * FROM (VALUES
('General Fund', 50000.00),
('Building Fund', 25000.00),
('Missions', 15000.00)
) AS goals(fund_name, monthly_goal)
),
current_month_giving AS (
SELECT
f.fund_id,
f.name as fund_name,
COUNT(DISTINCT des.designation_id) as designation_count,
COUNT(DISTINCT d.donation_id) as donation_count,
SUM(des.amount_cents) / 100.0 as amount_received
FROM planning_center.giving_funds f
-- Join funds to designations via designation_relationships (type='Fund')
LEFT JOIN planning_center.giving_designations_relationships desr_fund
ON desr_fund.relationship_id = f.fund_id
AND desr_fund.relationship_type = 'Fund'
LEFT JOIN planning_center.giving_designations des
ON des.designation_id = desr_fund.designation_id
-- Join designations to donations via donation_relationships (type='Designation')
LEFT JOIN planning_center.giving_donations_relationships dr_des
ON dr_des.relationship_id = des.designation_id
AND dr_des.relationship_type = 'Designation'
LEFT JOIN planning_center.giving_donations d
ON d.donation_id = dr_des.donation_id
AND d.received_at >= DATE_TRUNC('month', CURRENT_DATE)
AND d.received_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
AND d.refunded = false
GROUP BY f.fund_id, f.name
),
ytd_giving AS (
SELECT
f.fund_id,
SUM(des.amount_cents) / 100.0 as ytd_amount
FROM planning_center.giving_funds f
-- Join funds to designations via designation_relationships (type='Fund')
LEFT JOIN planning_center.giving_designations_relationships desr_fund
ON desr_fund.relationship_id = f.fund_id
AND desr_fund.relationship_type = 'Fund'
LEFT JOIN planning_center.giving_designations des
ON des.designation_id = desr_fund.designation_id
-- Join designations to donations via donation_relationships (type='Designation')
LEFT JOIN planning_center.giving_donations_relationships dr_des
ON dr_des.relationship_id = des.designation_id
AND dr_des.relationship_type = 'Designation'
LEFT JOIN planning_center.giving_donations d
ON d.donation_id = dr_des.donation_id
AND d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
AND d.refunded = false
GROUP BY f.fund_id
)
SELECT
cmg.fund_name,
COALESCE(cmg.amount_received, 0) as month_to_date,
COALESCE(fg.monthly_goal, 0) as monthly_goal,
COALESCE(cmg.amount_received, 0) - COALESCE(fg.monthly_goal, 0) as variance,
CASE
WHEN fg.monthly_goal > 0 THEN
ROUND((cmg.amount_received / fg.monthly_goal) * 100, 2)
ELSE NULL
END as percent_of_goal,
COALESCE(ytd.ytd_amount, 0) as year_to_date,
COALESCE(fg.monthly_goal * EXTRACT(MONTH FROM CURRENT_DATE), 0) as ytd_goal,
cmg.donation_count as donations_this_month
FROM current_month_giving cmg
LEFT JOIN fund_goals fg ON cmg.fund_name = fg.fund_name
LEFT JOIN ytd_giving ytd ON cmg.fund_id = ytd.fund_id
ORDER BY COALESCE(cmg.amount_received, 0) DESC;
```
### Restricted vs Unrestricted Funds Report
```sql theme={null}
-- Restricted vs Unrestricted Fund Analysis
WITH fund_categories AS (
SELECT
fund_id,
name,
CASE
WHEN name ILIKE '%general%' OR is_default = true THEN 'Unrestricted'
WHEN name ILIKE '%building%' OR name ILIKE '%capital%' THEN 'Capital'
WHEN name ILIKE '%mission%' OR name ILIKE '%outreach%' THEN 'Missions'
WHEN name ILIKE '%benevolence%' OR name ILIKE '%compassion%' THEN 'Benevolence'
ELSE 'Other Restricted'
END as fund_category
FROM planning_center.giving_funds
),
monthly_by_category AS (
SELECT
fc.fund_category,
DATE_TRUNC('month', d.received_at) as month,
SUM(des.amount_cents) / 100.0 as amount
FROM fund_categories fc
-- Join funds to designations via designation_relationships (type='Fund')
JOIN planning_center.giving_designations_relationships desr_fund
ON desr_fund.relationship_id = fc.fund_id
AND desr_fund.relationship_type = 'Fund'
JOIN planning_center.giving_designations des
ON des.designation_id = desr_fund.designation_id
-- Join designations to donations via donation_relationships (type='Designation')
JOIN planning_center.giving_donations_relationships dr_des
ON dr_des.relationship_id = des.designation_id
AND dr_des.relationship_type = 'Designation'
JOIN planning_center.giving_donations d
ON d.donation_id = dr_des.donation_id
WHERE d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
AND d.refunded = false
GROUP BY fc.fund_category, DATE_TRUNC('month', d.received_at)
)
SELECT
TO_CHAR(month, 'FMMonth') as month_name,
SUM(CASE WHEN fund_category = 'Unrestricted' THEN amount ELSE 0 END) as unrestricted,
SUM(CASE WHEN fund_category = 'Capital' THEN amount ELSE 0 END) as capital,
SUM(CASE WHEN fund_category = 'Missions' THEN amount ELSE 0 END) as missions,
SUM(CASE WHEN fund_category = 'Benevolence' THEN amount ELSE 0 END) as benevolence,
SUM(CASE WHEN fund_category = 'Other Restricted' THEN amount ELSE 0 END) as other_restricted,
SUM(amount) as total_month
FROM monthly_by_category
GROUP BY month, TO_CHAR(month, 'FMMonth')
ORDER BY month;
```
## Campaign Reports
### Capital Campaign Progress Report
```sql theme={null}
-- Capital Campaign Progress Dashboard
WITH campaign_summary AS (
SELECT
pc.pledge_campaign_id,
pc.name as campaign_name,
pc.description,
pc.goal_cents / 100.0 as campaign_goal,
-- received_total_from_pledges_cents tracks donations fulfilling pledges
pc.received_total_from_pledges_cents / 100.0 as total_received,
pc.created_at as campaign_start,
pc.ends_at as campaign_end
FROM planning_center.giving_pledge_campaigns pc
WHERE pc.name ILIKE '%capital%' OR pc.name ILIKE '%building%' -- Adjust as needed
),
pledge_details AS (
SELECT
pr_campaign.relationship_id as pledge_campaign_id,
COUNT(DISTINCT p.pledge_id) as pledge_count,
COUNT(DISTINCT pr_person.relationship_id) as pledger_count,
AVG(p.amount_cents / 100.0) as avg_pledge,
MAX(p.amount_cents / 100.0) as largest_pledge,
SUM(p.amount_cents / 100.0) as total_pledged,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY p.amount_cents) / 100.0 as median_pledge
FROM planning_center.giving_pledges p
-- Join pledges to campaigns via pledge_relationships (type='PledgeCampaign')
JOIN planning_center.giving_pledges_relationships pr_campaign
ON p.pledge_id = pr_campaign.pledge_id
AND pr_campaign.relationship_type = 'PledgeCampaign'
-- Join pledges to people via pledge_relationships (type='Person')
LEFT JOIN planning_center.giving_pledges_relationships pr_person
ON p.pledge_id = pr_person.pledge_id
AND pr_person.relationship_type = 'Person'
GROUP BY pr_campaign.relationship_id
),
recent_activity AS (
SELECT
pr_campaign.relationship_id as pledge_campaign_id,
SUM(CASE
WHEN p.created_at >= CURRENT_DATE - INTERVAL '30 days'
THEN p.amount_cents / 100.0
ELSE 0
END) as pledges_last_30_days,
COUNT(CASE
WHEN p.created_at >= CURRENT_DATE - INTERVAL '7 days'
THEN p.pledge_id
END) as new_pledges_this_week
FROM planning_center.giving_pledges p
JOIN planning_center.giving_pledges_relationships pr_campaign
ON p.pledge_id = pr_campaign.pledge_id
AND pr_campaign.relationship_type = 'PledgeCampaign'
GROUP BY pr_campaign.relationship_id
)
SELECT
cs.campaign_name,
cs.campaign_goal,
COALESCE(pd.total_pledged, 0) as total_pledged,
cs.total_received,
ROUND((COALESCE(pd.total_pledged, 0) / NULLIF(cs.campaign_goal, 0)) * 100, 2) as percent_pledged,
ROUND((cs.total_received / NULLIF(COALESCE(pd.total_pledged, 0), 0)) * 100, 2) as fulfillment_rate,
cs.campaign_goal - cs.total_received as remaining_to_goal,
pd.pledge_count,
pd.pledger_count,
pd.avg_pledge,
pd.median_pledge,
pd.largest_pledge,
ra.pledges_last_30_days,
ra.new_pledges_this_week,
CASE
WHEN cs.campaign_end IS NOT NULL THEN
cs.campaign_end - CURRENT_DATE
ELSE NULL
END as days_remaining
FROM campaign_summary cs
LEFT JOIN pledge_details pd ON cs.pledge_campaign_id = pd.pledge_campaign_id
LEFT JOIN recent_activity ra ON cs.pledge_campaign_id = ra.pledge_campaign_id
ORDER BY cs.campaign_goal DESC;
```
## Tax and Compliance Reports
### IRS Form 990 Schedule B Preparation
Donors giving \$5,000 or more:
```sql theme={null}
-- Major Donors Report for IRS Form 990
WITH annual_giving AS (
SELECT
p.person_id,
p.first_name,
p.last_name,
p.donor_number,
SUM(d.amount_cents) / 100.0 as total_given,
COUNT(d.donation_id) as donation_count,
MIN(d.received_at) as first_donation,
MAX(d.received_at) as last_donation
FROM planning_center.giving_people p
JOIN planning_center.giving_donations_relationships dr
ON p.person_id = dr.relationship_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d
ON dr.donation_id = d.donation_id
WHERE EXTRACT(YEAR FROM d.received_at) = EXTRACT(YEAR FROM CURRENT_DATE)
AND d.refunded = false
GROUP BY p.person_id, p.first_name, p.last_name, p.donor_number
HAVING SUM(d.amount_cents) >= 500000 -- $5,000 in cents
)
SELECT
ROW_NUMBER() OVER (ORDER BY total_given DESC) as rank,
donor_number,
-- For privacy, only show initials in reports
SUBSTRING(first_name, 1, 1) || '.' || SUBSTRING(last_name, 1, 1) || '.' as donor_initials,
total_given,
donation_count,
TO_CHAR(first_donation, 'MM/DD/YYYY') as first_gift_date,
TO_CHAR(last_donation, 'MM/DD/YYYY') as last_gift_date,
ROUND(total_given * 100.0 / SUM(total_given) OVER (), 2) as percent_of_major_gifts
FROM annual_giving
ORDER BY total_given DESC;
```
### Non-Cash Contributions Report
```sql theme={null}
-- Non-Cash (In-Kind) Contributions Summary
SELECT
DATE_TRUNC('month', ik.received_on) as month,
COUNT(*) as in_kind_donation_count,
SUM(ik.fair_market_value_cents) / 100.0 as total_in_kind_value,
STRING_AGG(DISTINCT ik.description, ', ' ORDER BY ik.description) as contribution_descriptions
FROM planning_center.giving_in_kind_donations ik
WHERE ik.received_on >= DATE_TRUNC('year', CURRENT_DATE)
GROUP BY DATE_TRUNC('month', ik.received_on)
ORDER BY month DESC;
```
## Trend Analysis Reports
### 13-Month Giving Trend
Shows monthly patterns over the past year:
```sql theme={null}
-- 13-Month Rolling Giving Trend
WITH monthly_stats AS (
SELECT
DATE_TRUNC('month', received_at) as month,
TO_CHAR(received_at, 'Mon YY') as month_label,
COUNT(DISTINCT d.donation_id) as donation_count,
COUNT(DISTINCT dr.relationship_id) as unique_donors,
SUM(d.amount_cents) / 100.0 as total_amount,
AVG(d.amount_cents) / 100.0 as avg_donation
FROM planning_center.giving_donations d
LEFT JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
WHERE d.received_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '13 months')
AND d.refunded = false
GROUP BY DATE_TRUNC('month', received_at), TO_CHAR(received_at, 'Mon YY')
),
with_calculations AS (
SELECT
*,
LAG(total_amount, 1) OVER (ORDER BY month) as prev_month,
LAG(total_amount, 12) OVER (ORDER BY month) as same_month_last_year,
AVG(total_amount) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) as three_month_avg
FROM monthly_stats
)
SELECT
month_label,
donation_count,
unique_donors,
total_amount,
avg_donation,
ROUND(((total_amount - prev_month) / NULLIF(prev_month, 0)) * 100, 2) as month_over_month_pct,
ROUND(((total_amount - same_month_last_year) / NULLIF(same_month_last_year, 0)) * 100, 2) as year_over_year_pct,
ROUND(three_month_avg, 2) as three_month_rolling_avg
FROM with_calculations
ORDER BY month DESC
LIMIT 13;
```
### Donor Retention Cohort Analysis
```sql theme={null}
-- Donor Retention Cohort Analysis
WITH donor_first_gift AS (
SELECT
dr.relationship_id as person_id,
DATE_TRUNC('month', MIN(d.received_at)) as cohort_month
FROM planning_center.giving_donations d
JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
WHERE d.refunded = false
GROUP BY dr.relationship_id
),
donor_activity AS (
SELECT
dfg.person_id,
dfg.cohort_month,
DATE_TRUNC('month', d.received_at) as activity_month,
EXTRACT(YEAR FROM AGE(DATE_TRUNC('month', d.received_at), dfg.cohort_month)) * 12 +
EXTRACT(MONTH FROM AGE(DATE_TRUNC('month', d.received_at), dfg.cohort_month)) as months_since_first
FROM donor_first_gift dfg
JOIN planning_center.giving_donations_relationships dr
ON dfg.person_id = dr.relationship_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d
ON dr.donation_id = d.donation_id
WHERE d.refunded = false
),
cohort_retention AS (
SELECT
cohort_month,
COUNT(DISTINCT CASE WHEN months_since_first = 0 THEN person_id END) as month_0,
COUNT(DISTINCT CASE WHEN months_since_first = 1 THEN person_id END) as month_1,
COUNT(DISTINCT CASE WHEN months_since_first = 2 THEN person_id END) as month_2,
COUNT(DISTINCT CASE WHEN months_since_first = 3 THEN person_id END) as month_3,
COUNT(DISTINCT CASE WHEN months_since_first = 6 THEN person_id END) as month_6,
COUNT(DISTINCT CASE WHEN months_since_first = 12 THEN person_id END) as month_12
FROM donor_activity
WHERE cohort_month >= CURRENT_DATE - INTERVAL '13 months'
GROUP BY cohort_month
)
SELECT
TO_CHAR(cohort_month, 'Mon YYYY') as cohort,
month_0 as new_donors,
ROUND(month_1 * 100.0 / NULLIF(month_0, 0), 2) as month_1_retention_pct,
ROUND(month_2 * 100.0 / NULLIF(month_0, 0), 2) as month_2_retention_pct,
ROUND(month_3 * 100.0 / NULLIF(month_0, 0), 2) as month_3_retention_pct,
ROUND(month_6 * 100.0 / NULLIF(month_0, 0), 2) as month_6_retention_pct,
ROUND(month_12 * 100.0 / NULLIF(month_0, 0), 2) as month_12_retention_pct
FROM cohort_retention
ORDER BY cohort_month DESC;
```
## Export-Ready Reports
### CSV Export for Mail Merge
```sql theme={null}
-- Donor List for Mail Merge Export
SELECT
p.donor_number,
p.first_name,
p.last_name,
SUM(d.amount_cents) / 100.0 as total_given_ytd,
MAX(d.received_at) as last_gift_date,
COUNT(d.donation_id) as gift_count,
-- Add any address fields if available in your system
'Thank you for your generous support!' as merge_message
FROM planning_center.giving_people p
JOIN planning_center.giving_donations_relationships dr
ON p.person_id = dr.relationship_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d
ON dr.donation_id = d.donation_id
WHERE d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
AND d.refunded = false
GROUP BY p.person_id, p.donor_number, p.first_name, p.last_name
HAVING SUM(d.amount_cents) > 0
ORDER BY p.last_name, p.first_name;
```
## Report Best Practices
### 1. Data Privacy
* Use donor numbers instead of names in public reports
* Consider using initials for sensitive reports
* Always respect donor privacy preferences
### 2. Performance Considerations
* Use CTEs to break down complex calculations
* Index on commonly filtered columns (dates, amounts)
* Consider materialized views for frequently-run reports
### 3. Accuracy Checks
* Always exclude refunded donations unless specifically needed
* Verify date ranges match reporting requirements
* Cross-check totals with source systems
### 4. Report Scheduling
* Executive reports: Monthly
* Board reports: Quarterly
* Donor statements: Quarterly or Annually
* Trend analysis: Monthly or Weekly
## Next Steps
* Review the [Data Model](/planning-center/giving/data-model) for complete table documentation
* Return to [Advanced Queries](/planning-center/giving/advanced-queries) for more query techniques
* Check [Basic Queries](/planning-center/giving/basic-queries) for fundamental concepts
# Advanced Planning Center Groups Queries
Source: https://docs.getparable.io/planning-center/groups/advanced-queries
Advanced Groups SQL combining multiple tables and window functions to analyze group health, attendance consistency, and member retention over time.
Master complex SQL patterns to gain deep insights into your groups ministry. These queries combine multiple tables, use window functions, and employ advanced SQL techniques.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Groups module live in the `planning_center` schema. Always prefix table names with `planning_center.` in advanced queries.
✅ CORRECT: `SELECT * FROM planning_center.groups_memberships`
❌ INCORRECT: `SELECT * FROM groups_memberships`
### Row Level Security (RLS)
Row Level Security automatically enforces:
* **tenant\_organization\_id** – results scoped to your organization
* **system\_status** – active records returned by default
**Skip manual filters for these columns**—RLS already applies them and redundant predicates can suppress data or degrade performance:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus your logic on ministry-specific signals (archived status, leader roles, attendance) while trusting RLS for tenancy and system status.
## Table of Contents
* [Group Health Metrics](#group-health-metrics)
* [Attendance Analytics](#attendance-analytics)
* [Member Engagement Scoring](#member-engagement-scoring)
* [Growth Trends](#growth-trends)
* [Leadership Analysis](#leadership-analysis)
* [Predictive Indicators](#predictive-indicators)
* [Performance Optimization](#performance-optimization)
## Group Health Metrics
### Comprehensive Group Health Score
```sql theme={null}
-- Calculate a health score for each group based on multiple factors
WITH group_metrics AS (
SELECT
g.group_id,
g.name,
g.memberships_count,
g.created_at,
-- Member metrics
COUNT(DISTINCT mr.membership_id) as actual_members,
COUNT(DISTINCT CASE WHEN m.role = 'leader' THEN mr.membership_id END) as leader_count,
-- Event metrics (last 90 days)
COUNT(DISTINCT e.event_id) as recent_events,
COUNT(DISTINCT CASE WHEN e.canceled = false THEN e.event_id END) as completed_events,
MAX(e.starts_at) as last_event_date,
-- Attendance metrics
AVG(CASE WHEN a.attended = true THEN 1 ELSE 0 END) as avg_attendance_rate,
-- Calculate weeks since creation
EXTRACT(EPOCH FROM (CURRENT_DATE - g.created_at)) / 604800 as weeks_active
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m
ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
LEFT JOIN planning_center.groups_events e
ON e.event_id = er.event_id
AND e.starts_at >= CURRENT_DATE - INTERVAL '90 days'
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = aer.attendance_id
WHERE g.archived_at IS NULL
GROUP BY g.group_id, g.name, g.memberships_count, g.created_at
)
SELECT
group_id,
name,
actual_members,
leader_count,
recent_events,
ROUND(avg_attendance_rate * 100, 1) as attendance_rate,
-- Calculate health score (0-100)
ROUND(
(
-- Size score (optimal 8-15 members)
CASE
WHEN actual_members BETWEEN 8 AND 15 THEN 25
WHEN actual_members BETWEEN 6 AND 7 OR actual_members BETWEEN 16 AND 20 THEN 15
WHEN actual_members > 0 THEN 5
ELSE 0
END +
-- Leadership score
CASE
WHEN leader_count >= 2 THEN 25
WHEN leader_count = 1 THEN 15
ELSE 0
END +
-- Activity score
CASE
WHEN recent_events >= 12 THEN 25 -- Weekly meetings
WHEN recent_events >= 6 THEN 15 -- Bi-weekly
WHEN recent_events >= 3 THEN 10 -- Monthly
WHEN recent_events > 0 THEN 5
ELSE 0
END +
-- Attendance score
COALESCE(avg_attendance_rate * 25, 0)
)::numeric,
1
) as health_score,
-- Status indicators
CASE
WHEN last_event_date IS NULL THEN 'No Events'
WHEN last_event_date < CURRENT_DATE - INTERVAL '30 days' THEN 'Inactive'
WHEN last_event_date < CURRENT_DATE - INTERVAL '14 days' THEN 'Low Activity'
ELSE 'Active'
END as activity_status
FROM group_metrics
ORDER BY health_score DESC;
```
### Groups at Risk
```sql theme={null}
-- Identify groups that may need pastoral attention
WITH risk_indicators AS (
SELECT
g.group_id,
g.name,
g.memberships_count,
-- Member risk factors
COUNT(DISTINCT mr.membership_id) as current_members,
COUNT(DISTINCT CASE WHEN m.role = 'leader' THEN mr.membership_id END) as leaders,
COUNT(DISTINCT CASE WHEN m.joined_at > CURRENT_DATE - INTERVAL '30 days' THEN mr.membership_id END) as new_members,
-- Event risk factors
COUNT(DISTINCT e.event_id) FILTER (WHERE e.starts_at > CURRENT_DATE - INTERVAL '30 days') as recent_events,
COUNT(DISTINCT e.event_id) FILTER (WHERE e.canceled = true AND e.canceled_at > CURRENT_DATE - INTERVAL '30 days') as canceled_events,
MAX(e.starts_at) as last_event,
-- Calculate risk factors
CASE WHEN COUNT(DISTINCT CASE WHEN m.role = 'leader' THEN mr.membership_id END) = 0 THEN 1 ELSE 0 END as no_leader,
CASE WHEN g.memberships_count <= 3 THEN 1 ELSE 0 END as too_small,
CASE WHEN MAX(e.starts_at) < CURRENT_DATE - INTERVAL '30 days' OR MAX(e.starts_at) IS NULL THEN 1 ELSE 0 END as inactive
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
LEFT JOIN planning_center.groups_events e ON e.event_id = er.event_id
WHERE g.archived_at IS NULL
GROUP BY g.group_id, g.name, g.memberships_count
)
SELECT
group_id,
name,
current_members,
leaders,
recent_events,
canceled_events,
no_leader + too_small + inactive as risk_score,
ARRAY_REMOVE(ARRAY[
CASE WHEN no_leader = 1 THEN 'No Leader' END,
CASE WHEN too_small = 1 THEN 'Too Small' END,
CASE WHEN inactive = 1 THEN 'Inactive' END
], NULL) as risk_factors,
CASE
WHEN no_leader + too_small + inactive >= 2 THEN 'High Risk'
WHEN no_leader + too_small + inactive = 1 THEN 'Medium Risk'
ELSE 'Low Risk'
END as risk_level
FROM risk_indicators
WHERE no_leader + too_small + inactive > 0
ORDER BY risk_score DESC, current_members;
```
## Attendance Analytics
### Attendance Patterns by Day and Time
```sql theme={null}
-- Analyze when groups meet and attendance rates
WITH event_attendance AS (
SELECT
e.event_id,
e.name as event_name,
e.starts_at,
EXTRACT(DOW FROM e.starts_at) as day_of_week,
EXTRACT(HOUR FROM e.starts_at) as hour_of_day,
TO_CHAR(e.starts_at, 'FMDay') as day_name,
CASE
WHEN EXTRACT(HOUR FROM e.starts_at) < 12 THEN 'Morning'
WHEN EXTRACT(HOUR FROM e.starts_at) < 17 THEN 'Afternoon'
ELSE 'Evening'
END as time_period,
COUNT(a.attendance_id) as total_registered,
COUNT(a.attendance_id) FILTER (WHERE a.attended = true) as attended_count
FROM planning_center.groups_events e
LEFT JOIN planning_center.groups_attendances_relationships ar
ON ar.relationship_type = 'Event' AND ar.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = ar.attendance_id
WHERE e.starts_at >= CURRENT_DATE - INTERVAL '90 days'
AND e.starts_at < CURRENT_DATE
AND e.canceled = false
GROUP BY e.event_id, e.name, e.starts_at
)
SELECT
day_name,
time_period,
COUNT(*) as event_count,
SUM(total_registered) as total_registered,
SUM(attended_count) as total_attended,
ROUND(AVG(CASE WHEN total_registered > 0
THEN attended_count::numeric / total_registered * 100
ELSE 0 END), 1) as avg_attendance_rate,
ROUND(AVG(attended_count), 1) as avg_attendees_per_event
FROM event_attendance
GROUP BY day_of_week, day_name, time_period
ORDER BY day_of_week,
CASE time_period
WHEN 'Morning' THEN 1
WHEN 'Afternoon' THEN 2
ELSE 3
END;
```
### Member Attendance Consistency
```sql theme={null}
-- Identify consistent vs sporadic attendees
WITH member_attendance AS (
SELECT
p.person_id,
mr_group.relationship_id as group_id,
g.name as group_name,
COUNT(DISTINCT e.event_id) as events_available,
COUNT(DISTINCT CASE WHEN a.attended = true THEN e.event_id END) as events_attended,
MIN(e.starts_at) as first_event,
MAX(e.starts_at) as last_event,
-- Calculate weeks between first and last event
GREATEST(1, EXTRACT(EPOCH FROM (MAX(e.starts_at) - MIN(e.starts_at))) / 604800) as weeks_span
FROM planning_center.groups_people p
JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.relationship_id = p.person_id AND mr_person.relationship_type = 'Person'
JOIN planning_center.groups_memberships m ON m.membership_id = mr_person.membership_id
JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.membership_id = m.membership_id AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_groups g ON g.group_id = mr_group.relationship_id
JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
JOIN planning_center.groups_events e ON e.event_id = er.event_id
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a ON a.attendance_id = aer.attendance_id
LEFT JOIN planning_center.groups_attendances_relationships apr
ON apr.attendance_id = a.attendance_id AND apr.relationship_type = 'Person'
AND apr.relationship_id = p.person_id
WHERE e.starts_at >= CURRENT_DATE - INTERVAL '90 days'
AND e.starts_at < CURRENT_DATE
AND e.canceled = false
AND g.archived_at IS NULL
GROUP BY p.person_id, mr_group.relationship_id, g.name
)
SELECT
person_id,
group_name,
events_available,
events_attended,
ROUND(events_attended::numeric / NULLIF(events_available, 0) * 100, 1) as attendance_rate,
ROUND(events_attended::numeric / weeks_span, 2) as events_per_week,
CASE
WHEN events_attended::numeric / NULLIF(events_available, 0) >= 0.75 THEN 'Consistent'
WHEN events_attended::numeric / NULLIF(events_available, 0) >= 0.50 THEN 'Regular'
WHEN events_attended::numeric / NULLIF(events_available, 0) >= 0.25 THEN 'Occasional'
ELSE 'Rare'
END as attendance_category,
weeks_span as weeks_active
FROM member_attendance
WHERE events_available > 0
ORDER BY attendance_rate DESC, events_attended DESC;
```
## Member Engagement Scoring
This query is a custom SQL example. If you are looking for the engagement
score your team sees in Parable, read
[Engagement Scoring](/planning-center/engagement-scoring) first.
### Multi-Dimensional Engagement Score
```sql theme={null}
-- Calculate comprehensive engagement score for each member
WITH member_activity AS (
SELECT
p.person_id,
-- Group participation
COUNT(DISTINCT mr_group.relationship_id) as groups_count,
COUNT(DISTINCT CASE WHEN m.role = 'leader' THEN mr_group.relationship_id END) as groups_led,
MIN(m.joined_at) as earliest_join,
-- Event attendance (last 90 days)
COUNT(DISTINCT e.event_id) FILTER (WHERE apr.relationship_id = p.person_id) as events_registered,
COUNT(DISTINCT e.event_id) FILTER (WHERE apr.relationship_id = p.person_id AND a.attended = true) as events_attended,
-- Recent activity
MAX(CASE WHEN apr.relationship_id = p.person_id THEN e.starts_at END) as last_attended_event,
COUNT(DISTINCT e.event_id) FILTER (
WHERE apr.relationship_id = p.person_id
AND a.attended = true
AND e.starts_at >= CURRENT_DATE - INTERVAL '30 days'
) as recent_attendances
FROM planning_center.groups_people p
LEFT JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.relationship_id = p.person_id AND mr_person.relationship_type = 'Person'
LEFT JOIN planning_center.groups_memberships m ON m.membership_id = mr_person.membership_id
LEFT JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.membership_id = m.membership_id AND mr_group.relationship_type = 'Group'
LEFT JOIN planning_center.groups_groups g ON g.group_id = mr_group.relationship_id AND g.archived_at IS NULL
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
LEFT JOIN planning_center.groups_events e ON e.event_id = er.event_id
AND e.starts_at >= CURRENT_DATE - INTERVAL '90 days'
AND e.canceled = false
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a ON a.attendance_id = aer.attendance_id
LEFT JOIN planning_center.groups_attendances_relationships apr
ON apr.attendance_id = a.attendance_id AND apr.relationship_type = 'Person'
GROUP BY p.person_id
),
engagement_scores AS (
SELECT
person_id,
groups_count,
groups_led,
events_attended,
recent_attendances,
-- Calculate component scores
LEAST(groups_count * 10, 30) as group_score, -- Max 30 points
groups_led * 15 as leadership_score, -- 15 points per group led
LEAST(events_attended * 2, 30) as attendance_score, -- Max 30 points
LEAST(recent_attendances * 5, 25) as recency_score, -- Max 25 points
-- Tenure bonus
CASE
WHEN earliest_join < CURRENT_DATE - INTERVAL '2 years' THEN 10
WHEN earliest_join < CURRENT_DATE - INTERVAL '1 year' THEN 5
ELSE 0
END as tenure_bonus
FROM member_activity
)
SELECT
person_id,
groups_count,
groups_led,
events_attended,
recent_attendances,
group_score + leadership_score + attendance_score + recency_score + tenure_bonus as total_engagement_score,
CASE
WHEN group_score + leadership_score + attendance_score + recency_score + tenure_bonus >= 75 THEN 'Highly Engaged'
WHEN group_score + leadership_score + attendance_score + recency_score + tenure_bonus >= 50 THEN 'Engaged'
WHEN group_score + leadership_score + attendance_score + recency_score + tenure_bonus >= 25 THEN 'Moderately Engaged'
WHEN group_score + leadership_score + attendance_score + recency_score + tenure_bonus > 0 THEN 'Low Engagement'
ELSE 'Inactive'
END as engagement_level
FROM engagement_scores
ORDER BY total_engagement_score DESC;
```
## Growth Trends
### Monthly Growth Analysis
```sql theme={null}
-- Track growth trends across multiple dimensions
WITH monthly_metrics AS (
SELECT
DATE_TRUNC('month', series.month) as month,
-- New groups created
COUNT(DISTINCT g.group_id) FILTER (
WHERE DATE_TRUNC('month', g.created_at) = DATE_TRUNC('month', series.month)
) as new_groups,
-- Total active groups
COUNT(DISTINCT g.group_id) FILTER (
WHERE g.created_at <= series.month
AND (g.archived_at IS NULL OR g.archived_at > series.month)
) as active_groups,
-- New memberships
COUNT(DISTINCT m.membership_id) FILTER (
WHERE DATE_TRUNC('month', m.joined_at) = DATE_TRUNC('month', series.month)
) as new_memberships,
-- Total memberships
COUNT(DISTINCT m.membership_id) FILTER (
WHERE m.joined_at <= series.month
) as total_memberships,
-- Events held
COUNT(DISTINCT e.event_id) FILTER (
WHERE DATE_TRUNC('month', e.starts_at) = DATE_TRUNC('month', series.month)
AND e.canceled = false
) as events_held
FROM generate_series(
DATE_TRUNC('month', CURRENT_DATE - INTERVAL '12 months'),
DATE_TRUNC('month', CURRENT_DATE),
INTERVAL '1 month'
) as series(month)
CROSS JOIN planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
LEFT JOIN planning_center.groups_events e ON e.event_id = er.event_id
GROUP BY series.month
)
SELECT
TO_CHAR(month, 'YYYY-MM') as month,
new_groups,
active_groups,
new_memberships,
total_memberships,
events_held,
-- Calculate growth rates
LAG(active_groups) OVER (ORDER BY month) as prev_active_groups,
CASE
WHEN LAG(active_groups) OVER (ORDER BY month) > 0
THEN ROUND((active_groups - LAG(active_groups) OVER (ORDER BY month))::numeric /
LAG(active_groups) OVER (ORDER BY month) * 100, 1)
ELSE NULL
END as group_growth_rate,
LAG(total_memberships) OVER (ORDER BY month) as prev_memberships,
CASE
WHEN LAG(total_memberships) OVER (ORDER BY month) > 0
THEN ROUND((total_memberships - LAG(total_memberships) OVER (ORDER BY month))::numeric /
LAG(total_memberships) OVER (ORDER BY month) * 100, 1)
ELSE NULL
END as membership_growth_rate
FROM monthly_metrics
ORDER BY month DESC;
```
### Group Lifecycle Analysis
```sql theme={null}
-- Analyze how groups evolve over time
WITH group_lifecycle AS (
SELECT
g.group_id,
g.name,
g.created_at,
g.archived_at,
g.memberships_count as current_size,
-- Calculate age in months
EXTRACT(YEAR FROM AGE(COALESCE(g.archived_at, CURRENT_DATE), g.created_at)) * 12 +
EXTRACT(MONTH FROM AGE(COALESCE(g.archived_at, CURRENT_DATE), g.created_at)) as age_months,
-- Get membership history
COUNT(DISTINCT mr.membership_id) as total_members_ever,
COUNT(DISTINCT CASE WHEN m.joined_at >= CURRENT_DATE - INTERVAL '90 days' THEN mr.membership_id END) as recent_joins,
MIN(m.joined_at) as first_member_joined,
MAX(m.joined_at) as last_member_joined,
-- Event activity
COUNT(DISTINCT e.event_id) as total_events,
COUNT(DISTINCT CASE WHEN e.starts_at >= CURRENT_DATE - INTERVAL '90 days' THEN e.event_id END) as recent_events
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
LEFT JOIN planning_center.groups_events e ON e.event_id = er.event_id
GROUP BY g.group_id, g.name, g.created_at, g.archived_at, g.memberships_count
)
SELECT
group_id,
name,
age_months,
current_size,
total_members_ever,
recent_joins,
recent_events,
CASE
WHEN archived_at IS NOT NULL THEN 'Archived'
WHEN age_months < 3 THEN 'New'
WHEN age_months < 12 THEN 'Growing'
WHEN recent_events = 0 THEN 'Dormant'
WHEN recent_joins > 0 THEN 'Active'
ELSE 'Stable'
END as lifecycle_stage,
CASE
WHEN total_members_ever > 0
THEN ROUND(current_size::numeric / total_members_ever * 100, 1)
ELSE 0
END as retention_rate,
ROUND(total_events::numeric / NULLIF(age_months, 0), 1) as events_per_month
FROM group_lifecycle
ORDER BY
CASE
WHEN archived_at IS NOT NULL THEN 4
WHEN age_months < 3 THEN 1
WHEN recent_joins > 0 THEN 2
ELSE 3
END,
current_size DESC;
```
## Leadership Analysis
### Leadership Coverage and Capacity
```sql theme={null}
-- Analyze leadership distribution and capacity
WITH leadership_metrics AS (
SELECT
p.person_id,
COUNT(DISTINCT mr_group.relationship_id) FILTER (WHERE m.role = 'leader') as groups_leading,
COUNT(DISTINCT mr_group.relationship_id) FILTER (WHERE m.role = 'member') as groups_participating,
ARRAY_AGG(DISTINCT g.name ORDER BY g.name) FILTER (WHERE m.role = 'leader') as groups_led_names,
SUM(g.memberships_count) FILTER (WHERE m.role = 'leader') as total_members_under_leadership,
MAX(m.joined_at) FILTER (WHERE m.role = 'leader') as became_leader_date
FROM planning_center.groups_people p
JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.relationship_id = p.person_id AND mr_person.relationship_type = 'Person'
JOIN planning_center.groups_memberships m ON m.membership_id = mr_person.membership_id
JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.membership_id = m.membership_id AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_groups g ON g.group_id = mr_group.relationship_id
WHERE g.archived_at IS NULL
GROUP BY p.person_id
),
group_leadership AS (
SELECT
g.group_id,
g.name,
g.memberships_count,
COUNT(DISTINCT mr_person.relationship_id) FILTER (WHERE m.role = 'leader') as leader_count,
COUNT(DISTINCT mr_person.relationship_id) FILTER (WHERE m.role = 'member') as member_count,
ARRAY_AGG(DISTINCT mr_person.relationship_id ORDER BY mr_person.relationship_id) FILTER (WHERE m.role = 'leader') as leader_ids
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.membership_id = m.membership_id AND mr_person.relationship_type = 'Person'
WHERE g.archived_at IS NULL
GROUP BY g.group_id, g.name, g.memberships_count
)
SELECT
'Leadership Overview' as metric_category,
'Total Leaders' as metric,
COUNT(DISTINCT person_id) FILTER (WHERE groups_leading > 0)::text as value
FROM leadership_metrics
UNION ALL
SELECT
'Leadership Overview',
'Avg Groups per Leader',
ROUND(AVG(groups_leading) FILTER (WHERE groups_leading > 0), 2)::text
FROM leadership_metrics
UNION ALL
SELECT
'Leadership Overview',
'Leaders Leading Multiple Groups',
COUNT(DISTINCT person_id) FILTER (WHERE groups_leading > 1)::text
FROM leadership_metrics
UNION ALL
SELECT
'Group Coverage',
'Groups Without Leaders',
COUNT(*)::text
FROM group_leadership
WHERE leader_count = 0
UNION ALL
SELECT
'Group Coverage',
'Groups with Single Leader',
COUNT(*)::text
FROM group_leadership
WHERE leader_count = 1
UNION ALL
SELECT
'Group Coverage',
'Groups with Multiple Leaders',
COUNT(*)::text
FROM group_leadership
WHERE leader_count > 1
UNION ALL
SELECT
'Leadership Capacity',
'Avg Members per Leader',
ROUND(SUM(memberships_count)::numeric / NULLIF(SUM(leader_count), 0), 1)::text
FROM group_leadership
WHERE leader_count > 0;
```
### Potential Leader Identification
```sql theme={null}
-- Identify members who might be ready for leadership
WITH member_qualifications AS (
SELECT
p.person_id,
-- Current involvement
COUNT(DISTINCT mr_group.relationship_id) as groups_count,
BOOL_OR(m.role = 'leader') as is_current_leader,
MIN(m.joined_at) as first_joined,
-- Attendance record (last 90 days)
COUNT(DISTINCT e.event_id) FILTER (WHERE a.attended = true) as events_attended,
COUNT(DISTINCT e.event_id) as events_available,
-- Consistency metrics
COUNT(DISTINCT DATE_TRUNC('week', e.starts_at)) FILTER (WHERE a.attended = true) as weeks_attended,
-- Tenure
EXTRACT(YEAR FROM AGE(CURRENT_DATE, MIN(m.joined_at))) * 12 +
EXTRACT(MONTH FROM AGE(CURRENT_DATE, MIN(m.joined_at))) as tenure_months
FROM planning_center.groups_people p
JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.relationship_id = p.person_id AND mr_person.relationship_type = 'Person'
JOIN planning_center.groups_memberships m ON m.membership_id = mr_person.membership_id
JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.membership_id = m.membership_id AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_groups g ON g.group_id = mr_group.relationship_id AND g.archived_at IS NULL
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
LEFT JOIN planning_center.groups_events e ON e.event_id = er.event_id
AND e.starts_at >= CURRENT_DATE - INTERVAL '90 days'
AND e.canceled = false
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a ON a.attendance_id = aer.attendance_id
LEFT JOIN planning_center.groups_attendances_relationships apr
ON apr.attendance_id = a.attendance_id
AND apr.relationship_type = 'Person'
AND apr.relationship_id = p.person_id
GROUP BY p.person_id
)
SELECT
person_id,
groups_count,
events_attended,
ROUND(events_attended::numeric / NULLIF(events_available, 0) * 100, 1) as attendance_rate,
tenure_months,
weeks_attended,
-- Calculate leadership readiness score
(
CASE WHEN tenure_months >= 12 THEN 20 ELSE tenure_months * 20 / 12 END + -- Tenure score
CASE WHEN events_attended::numeric / NULLIF(events_available, 0) >= 0.75 THEN 30
WHEN events_attended::numeric / NULLIF(events_available, 0) >= 0.50 THEN 20
ELSE 10 END + -- Attendance score
CASE WHEN weeks_attended >= 10 THEN 25 ELSE weeks_attended * 2.5 END + -- Consistency score
CASE WHEN groups_count > 1 THEN 15 ELSE groups_count * 15 END -- Involvement score
) as readiness_score,
CASE
WHEN tenure_months >= 12
AND events_attended::numeric / NULLIF(events_available, 0) >= 0.75
AND weeks_attended >= 10
THEN 'Ready Now'
WHEN tenure_months >= 6
AND events_attended::numeric / NULLIF(events_available, 0) >= 0.50
THEN 'Developing'
ELSE 'Future Potential'
END as leadership_potential
FROM member_qualifications
WHERE is_current_leader = false
AND events_available > 0
AND tenure_months >= 3
ORDER BY readiness_score DESC
LIMIT 20;
```
## Predictive Indicators
### Group Sustainability Prediction
```sql theme={null}
-- Predict which groups might struggle based on patterns
WITH group_indicators AS (
SELECT
g.group_id,
g.name,
g.created_at,
g.memberships_count,
-- Size trajectory
COUNT(DISTINCT mr.membership_id) as current_members,
COUNT(DISTINCT CASE WHEN m.joined_at >= CURRENT_DATE - INTERVAL '90 days' THEN mr.membership_id END) as new_members_90d,
COUNT(DISTINCT CASE WHEN m.joined_at >= CURRENT_DATE - INTERVAL '180 days'
AND m.joined_at < CURRENT_DATE - INTERVAL '90 days' THEN mr.membership_id END) as members_90_180d,
-- Leadership stability
COUNT(DISTINCT CASE WHEN m.role = 'leader' THEN mr.membership_id END) as leader_count,
MAX(CASE WHEN m.role = 'leader' THEN m.joined_at END) as last_leader_joined,
-- Event consistency
COUNT(DISTINCT e.event_id) FILTER (WHERE e.starts_at >= CURRENT_DATE - INTERVAL '30 days') as events_30d,
COUNT(DISTINCT e.event_id) FILTER (WHERE e.starts_at >= CURRENT_DATE - INTERVAL '90 days') as events_90d,
-- Attendance trend
AVG(CASE WHEN a.attended = true THEN 1 ELSE 0 END) FILTER (WHERE e.starts_at >= CURRENT_DATE - INTERVAL '30 days') as recent_attendance,
AVG(CASE WHEN a.attended = true THEN 1 ELSE 0 END) FILTER (WHERE e.starts_at >= CURRENT_DATE - INTERVAL '90 days'
AND e.starts_at < CURRENT_DATE - INTERVAL '30 days') as prior_attendance
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
LEFT JOIN planning_center.groups_events e ON e.event_id = er.event_id AND e.canceled = false
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a ON a.attendance_id = aer.attendance_id
WHERE g.archived_at IS NULL
AND g.created_at < CURRENT_DATE - INTERVAL '90 days' -- Established groups only
GROUP BY g.group_id, g.name, g.created_at, g.memberships_count
),
predictions AS (
SELECT
*,
-- Calculate risk scores
CASE WHEN current_members < members_90_180d THEN 2 ELSE 0 END as declining_membership,
CASE WHEN leader_count = 0 THEN 3 WHEN leader_count = 1 THEN 1 ELSE 0 END as leadership_risk,
CASE WHEN events_30d = 0 THEN 3 WHEN events_30d < 2 THEN 1 ELSE 0 END as activity_risk,
CASE WHEN recent_attendance < prior_attendance THEN 2 ELSE 0 END as attendance_decline,
CASE WHEN new_members_90d = 0 THEN 1 ELSE 0 END as no_new_members
FROM group_indicators
)
SELECT
group_id,
name,
current_members,
leader_count,
events_30d,
ROUND(recent_attendance * 100, 1) as recent_attendance_rate,
declining_membership + leadership_risk + activity_risk + attendance_decline + no_new_members as total_risk_score,
CASE
WHEN declining_membership + leadership_risk + activity_risk + attendance_decline + no_new_members >= 5 THEN 'High Risk - Immediate Attention'
WHEN declining_membership + leadership_risk + activity_risk + attendance_decline + no_new_members >= 3 THEN 'Medium Risk - Monitor Closely'
WHEN declining_membership + leadership_risk + activity_risk + attendance_decline + no_new_members >= 1 THEN 'Low Risk - Watch'
ELSE 'Healthy'
END as sustainability_prediction,
ARRAY_REMOVE(ARRAY[
CASE WHEN declining_membership > 0 THEN 'Declining Membership' END,
CASE WHEN leadership_risk > 0 THEN 'Leadership Issues' END,
CASE WHEN activity_risk > 0 THEN 'Low Activity' END,
CASE WHEN attendance_decline > 0 THEN 'Attendance Declining' END,
CASE WHEN no_new_members > 0 THEN 'No New Members' END
], NULL) as risk_factors
FROM predictions
WHERE declining_membership + leadership_risk + activity_risk + attendance_decline + no_new_members > 0
ORDER BY total_risk_score DESC, current_members DESC;
```
## Performance Optimization
### Dashboard Metrics Rollup
Your Parable database connection is **read-only**. You cannot create
materialized views or indexes through it. Run this query directly, schedule it
as a Parable report, or let your BI tool cache the result set.
```sql theme={null}
-- Frequently accessed group metrics — schedule as a report or BI dataset
WITH base_metrics AS (
SELECT
g.group_id,
g.name,
g.created_at,
g.archived_at,
g.memberships_count,
g.location_type_preference,
COUNT(DISTINCT mr.membership_id) as actual_members,
COUNT(DISTINCT CASE WHEN m.role = 'leader' THEN mr.membership_id END) as leader_count,
COUNT(DISTINCT e.event_id) FILTER (WHERE e.starts_at >= CURRENT_DATE - INTERVAL '30 days') as recent_events,
MAX(e.starts_at) as last_event,
AVG(CASE WHEN a.attended = true THEN 1 ELSE 0 END) as avg_attendance_rate
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
LEFT JOIN planning_center.groups_events e ON e.event_id = er.event_id AND e.canceled = false
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a ON a.attendance_id = aer.attendance_id
GROUP BY g.group_id, g.name, g.created_at, g.archived_at, g.memberships_count, g.location_type_preference
)
SELECT
group_id,
name,
created_at,
archived_at,
memberships_count,
location_type_preference,
actual_members,
leader_count,
recent_events,
last_event,
ROUND(avg_attendance_rate * 100, 1) as attendance_rate_percent,
CASE
WHEN archived_at IS NOT NULL THEN 'Archived'
WHEN last_event IS NULL OR last_event < CURRENT_DATE - INTERVAL '30 days' THEN 'Inactive'
WHEN leader_count = 0 THEN 'No Leader'
WHEN actual_members < 4 THEN 'Small'
ELSE 'Active'
END as status,
CURRENT_TIMESTAMP as last_refreshed
FROM base_metrics;
```
### Query Performance Analysis
```sql theme={null}
-- Analyze query patterns for optimization opportunities
WITH query_stats AS (
SELECT
'Groups Table' as table_name,
COUNT(*) as row_count,
pg_size_pretty(pg_relation_size('planning_center.groups_groups')) as table_size
FROM planning_center.groups_groups
UNION ALL
SELECT
'Memberships Table',
COUNT(*),
pg_size_pretty(pg_relation_size('planning_center.groups_memberships'))
FROM planning_center.groups_memberships
UNION ALL
SELECT
'Events Table',
COUNT(*),
pg_size_pretty(pg_relation_size('planning_center.groups_events'))
FROM planning_center.groups_events
UNION ALL
SELECT
'Attendances Table',
COUNT(*),
pg_size_pretty(pg_relation_size('planning_center.groups_attendances'))
FROM planning_center.groups_attendances
)
SELECT * FROM query_stats
ORDER BY row_count DESC;
```
## Best Practices
1. **Use CTEs for Complex Logic**: Break complex queries into logical steps using Common Table Expressions
2. **Filter Early**: Apply WHERE clauses as early as possible in your joins
3. **Use Window Functions**: Leverage OVER() clauses for running totals and rankings
4. **Index Key Columns**: Ensure frequently joined and filtered columns are indexed
5. **Monitor Performance**: Use EXPLAIN ANALYZE to understand query execution plans
## Next Steps
Ready to apply these queries to real ministry scenarios? Check out:
* [Reporting Examples](/planning-center/groups/reporting-examples) - Practical applications for ministry decision-making
# Basic Planning Center Groups Queries
Source: https://docs.getparable.io/planning-center/groups/basic-queries
Foundational SQL for Planning Center Groups: list active groups, count members, and see who joined a group within a given date range.
Start your journey into Groups data with these foundational queries. Each example builds your SQL skills while providing immediate value for your ministry.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Groups module live in the `planning_center` schema. Always prefix table names with `planning_center.` when querying.
✅ CORRECT: `SELECT * FROM planning_center.groups_groups`
❌ INCORRECT: `SELECT * FROM groups_groups`
### Row Level Security (RLS)
Row Level Security automatically enforces:
* **tenant\_organization\_id** – results scoped to your organization
* **system\_status** – only active records returned by default
**Skip manual filters for these columns**—RLS already applies them and extra predicates can suppress data or hurt performance:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Keep your filters focused on ministry context (archived groups, roles, schedules) while RLS handles tenant isolation.
## Table of Contents
* [Viewing Groups](#viewing-groups)
* [Finding Members](#finding-members)
* [Understanding Memberships](#understanding-memberships)
* [Working with Events](#working-with-events)
* [Tracking Attendance](#tracking-attendance)
* [Date-Based Queries](#date-based-queries)
* [Basic Aggregations](#basic-aggregations)
## Viewing Groups
### List All Active Groups
```sql theme={null}
-- See all your active groups with basic information
SELECT
group_id,
name,
description,
memberships_count,
schedule,
location_type_preference
FROM planning_center.groups_groups
WHERE archived_at IS NULL -- Only active groups
ORDER BY name;
```
### Find Groups by Name
```sql theme={null}
-- Search for groups containing specific words
SELECT
group_id,
name,
description,
memberships_count,
created_at
FROM planning_center.groups_groups
WHERE LOWER(name) LIKE '%youth%' -- Case-insensitive search
OR LOWER(description) LIKE '%youth%'
AND archived_at IS NULL
ORDER BY memberships_count DESC;
```
### Groups by Size
```sql theme={null}
-- Find large groups that might need to split
SELECT
group_id,
name,
memberships_count,
schedule,
location_type_preference
FROM planning_center.groups_groups
WHERE memberships_count > 12 -- Groups larger than 12
AND archived_at IS NULL
ORDER BY memberships_count DESC;
```
### Recently Created Groups
```sql theme={null}
-- See groups created in the last 90 days
SELECT
group_id,
name,
description,
memberships_count,
created_at
FROM planning_center.groups_groups
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
AND archived_at IS NULL
ORDER BY created_at DESC;
```
## Finding Members
### List All People in Groups
```sql theme={null}
-- Get all people registered in the Groups system
SELECT
person_id,
permissions,
created_at
FROM planning_center.groups_people
ORDER BY created_at DESC
LIMIT 100;
```
### People with Leadership Permissions
```sql theme={null}
-- Find people who can lead groups
SELECT
person_id,
permissions,
created_at
FROM planning_center.groups_people
WHERE permissions IN ('leader', 'administrator') -- Adjust based on your permission types
ORDER BY created_at DESC;
```
## Understanding Memberships
### View All Memberships
```sql theme={null}
-- See how people are connected to groups
SELECT
m.membership_id,
mr_person.relationship_id as person_id,
mr_group.relationship_id as group_id,
m.role,
m.joined_at
FROM planning_center.groups_memberships m
LEFT JOIN planning_center.groups_memberships_relationships mr_person
ON m.membership_id = mr_person.membership_id AND mr_person.relationship_type = 'Person'
LEFT JOIN planning_center.groups_memberships_relationships mr_group
ON m.membership_id = mr_group.membership_id AND mr_group.relationship_type = 'Group'
ORDER BY m.joined_at DESC
LIMIT 50;
```
### Find Leaders vs Members
```sql theme={null}
-- Count leaders and members across all groups
SELECT
role,
COUNT(*) as count
FROM planning_center.groups_memberships
GROUP BY role
ORDER BY count DESC;
```
### Recent Group Joins
```sql theme={null}
-- People who joined groups in the last 30 days
SELECT
m.membership_id,
mr_person.relationship_id as person_id,
mr_group.relationship_id as group_id,
m.role,
m.joined_at
FROM planning_center.groups_memberships m
LEFT JOIN planning_center.groups_memberships_relationships mr_person
ON m.membership_id = mr_person.membership_id AND mr_person.relationship_type = 'Person'
LEFT JOIN planning_center.groups_memberships_relationships mr_group
ON m.membership_id = mr_group.membership_id AND mr_group.relationship_type = 'Group'
WHERE m.joined_at >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY m.joined_at DESC;
```
### Connect Members to Groups
```sql theme={null}
-- See members with their group names
SELECT
m.membership_id,
mr_person.relationship_id as person_id,
m.role,
m.joined_at,
g.name as group_name,
g.schedule,
g.memberships_count
FROM planning_center.groups_memberships m
JOIN planning_center.groups_memberships_relationships mr_group
ON m.membership_id = mr_group.membership_id AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_groups g
ON mr_group.relationship_id = g.group_id
LEFT JOIN planning_center.groups_memberships_relationships mr_person
ON m.membership_id = mr_person.membership_id AND mr_person.relationship_type = 'Person'
WHERE g.archived_at IS NULL
ORDER BY m.joined_at DESC
LIMIT 100;
```
## Working with Events
### List Upcoming Events
```sql theme={null}
-- See events happening in the next 30 days
SELECT
event_id,
name,
description,
starts_at,
ends_at,
location_type_preference,
virtual_location_url
FROM planning_center.groups_events
WHERE starts_at >= CURRENT_TIMESTAMP
AND starts_at <= CURRENT_TIMESTAMP + INTERVAL '30 days'
AND canceled = false
ORDER BY starts_at;
```
### Find Canceled Events
```sql theme={null}
-- Review events that were canceled
SELECT
event_id,
name,
starts_at,
canceled_at,
description
FROM planning_center.groups_events
WHERE canceled = true
AND canceled_at >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY canceled_at DESC;
```
### Multi-Day Events
```sql theme={null}
-- Find retreats, camps, or multi-day events
SELECT
event_id,
name,
description,
starts_at,
ends_at,
(ends_at - starts_at) as duration
FROM planning_center.groups_events
WHERE multi_day = true
AND starts_at >= CURRENT_DATE
ORDER BY starts_at;
```
### Events with Virtual Options
```sql theme={null}
-- Find events with online participation
SELECT
event_id,
name,
starts_at,
location_type_preference,
virtual_location_url
FROM planning_center.groups_events
WHERE virtual_location_url IS NOT NULL
AND starts_at >= CURRENT_TIMESTAMP
ORDER BY starts_at
LIMIT 20;
```
## Tracking Attendance
### View Recent Attendance Records
```sql theme={null}
-- See who's been attending events
SELECT
attendance_id,
attended,
system_created_at
FROM planning_center.groups_attendances
WHERE system_created_at >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY system_created_at DESC
LIMIT 50;
```
### Attendance Rate Summary
```sql theme={null}
-- Calculate overall attendance rate
SELECT
COUNT(*) FILTER (WHERE attended = true) as attended_count,
COUNT(*) FILTER (WHERE attended = false) as absent_count,
COUNT(*) as total_records,
ROUND(
COUNT(*) FILTER (WHERE attended = true)::numeric /
NULLIF(COUNT(*), 0)::numeric * 100,
1
) as attendance_rate
FROM planning_center.groups_attendances
WHERE system_created_at >= CURRENT_DATE - INTERVAL '30 days';
```
## Date-Based Queries
### Groups Created This Year
```sql theme={null}
-- All groups started this year
SELECT
group_id,
name,
memberships_count,
created_at
FROM planning_center.groups_groups
WHERE created_at >= DATE_TRUNC('year', CURRENT_DATE)
AND archived_at IS NULL
ORDER BY created_at DESC;
```
### Weekly Event Schedule
```sql theme={null}
-- Events for the current week
SELECT
event_id,
name,
starts_at,
DATE_PART('dow', starts_at) as day_of_week,
TO_CHAR(starts_at, 'FMDay') as day_name,
TO_CHAR(starts_at, 'HH24:MI') as start_time
FROM planning_center.groups_events
WHERE starts_at >= DATE_TRUNC('week', CURRENT_DATE)
AND starts_at < DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '1 week'
AND canceled = false
ORDER BY starts_at;
```
### Monthly Membership Growth
```sql theme={null}
-- Track new memberships by month
SELECT
DATE_TRUNC('month', joined_at) as month,
COUNT(*) as new_memberships
FROM planning_center.groups_memberships
WHERE joined_at >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', joined_at)
ORDER BY month DESC;
```
## Basic Aggregations
### Group Statistics
```sql theme={null}
-- Overview of your groups ministry
SELECT
COUNT(*) as total_groups,
COUNT(*) FILTER (WHERE archived_at IS NULL) as active_groups,
COUNT(*) FILTER (WHERE archived_at IS NOT NULL) as archived_groups,
AVG(memberships_count) FILTER (WHERE archived_at IS NULL) as avg_group_size,
MAX(memberships_count) FILTER (WHERE archived_at IS NULL) as largest_group,
MIN(memberships_count) FILTER (WHERE archived_at IS NULL AND memberships_count > 0) as smallest_group
FROM planning_center.groups_groups;
```
### Groups by Meeting Type
```sql theme={null}
-- How groups prefer to meet
SELECT
location_type_preference,
COUNT(*) as group_count,
AVG(memberships_count) as avg_size
FROM planning_center.groups_groups
WHERE archived_at IS NULL
GROUP BY location_type_preference
ORDER BY group_count DESC;
```
### Membership Role Distribution
```sql theme={null}
-- Understand your leader to member ratio
SELECT
g.group_id,
g.name,
COUNT(*) FILTER (WHERE m.role = 'leader') as leaders,
COUNT(*) FILTER (WHERE m.role = 'member') as members,
COUNT(*) as total_members
FROM planning_center.groups_groups g
JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
JOIN planning_center.groups_memberships m ON m.membership_id = mr.membership_id
WHERE g.archived_at IS NULL
GROUP BY g.group_id, g.name
HAVING COUNT(*) > 0
ORDER BY total_members DESC
LIMIT 20;
```
### Event Frequency by Day
```sql theme={null}
-- Which days have the most events
SELECT
TO_CHAR(starts_at, 'FMDay') as day_name,
DATE_PART('dow', starts_at) as day_number,
COUNT(*) as event_count
FROM planning_center.groups_events
WHERE starts_at >= CURRENT_DATE - INTERVAL '90 days'
AND canceled = false
GROUP BY day_name, day_number
ORDER BY day_number;
```
### Groups Without Recent Events
```sql theme={null}
-- Find potentially inactive groups (no events in 60 days)
SELECT
g.group_id,
g.name,
g.memberships_count,
MAX(e.starts_at) as last_event_date
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_type = 'Group' AND er.relationship_id = g.group_id
LEFT JOIN planning_center.groups_events e
ON e.event_id = er.event_id
WHERE g.archived_at IS NULL
GROUP BY g.group_id, g.name, g.memberships_count
HAVING MAX(e.starts_at) < CURRENT_DATE - INTERVAL '60 days'
OR MAX(e.starts_at) IS NULL
ORDER BY last_event_date NULLS FIRST;
```
## Tips for Writing Queries
### 1. Check for Active Groups
Archived groups keep a non-null `archived_at`, so filter it out for active groups.
```sql theme={null}
SELECT group_id, name
FROM planning_center.groups_groups
WHERE archived_at IS NULL;
```
### 2. Handle NULL Values Properly
Use `IS NULL` / `IS NOT NULL` — never `= NULL`.
```sql theme={null}
SELECT event_id, name
FROM planning_center.groups_events
WHERE virtual_location_url IS NOT NULL -- has a virtual option
AND canceled_at IS NULL; -- not canceled
```
### 3. Use Date Functions
`CURRENT_DATE` is today, `CURRENT_TIMESTAMP` is now, and `DATE_TRUNC` snaps a timestamp to the start of a period.
```sql theme={null}
SELECT
DATE_TRUNC('month', starts_at) as month,
COUNT(*) as events
FROM planning_center.groups_events
WHERE starts_at >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY DATE_TRUNC('month', starts_at)
ORDER BY month;
```
### 4. Case-Insensitive Searches
Lowercase the column before comparing so casing does not matter.
```sql theme={null}
SELECT group_id, name
FROM planning_center.groups_groups
WHERE LOWER(name) LIKE '%youth%';
```
### 5. Aggregate with Filters
`FILTER (WHERE ...)` counts only the rows matching a condition, without a `CASE`.
```sql theme={null}
SELECT
COUNT(*) as total_events,
COUNT(*) FILTER (WHERE canceled = true) as canceled_events
FROM planning_center.groups_events;
```
## Common Issues & Solutions
### Issue: No results when joining tables
**Solution**: Make sure you're using the correct join columns and that data exists in both tables.
### Issue: Duplicate results
**Solution**: You might need to use DISTINCT or check your join conditions.
### Issue: Date comparisons not working
**Solution**: Ensure you're using the correct date/timestamp format and comparison operators.
### Issue: Group counts don't match
**Solution**: Check if you're filtering for active groups (`archived_at IS NULL`).
## Next Steps
Ready to connect data across tables? Continue with:
* [Data Model](/planning-center/groups/data-model) - Learn to join groups, members, and events
* [Advanced Queries](/planning-center/groups/advanced-queries) - Complex analysis and reporting
* [Reporting Examples](/planning-center/groups/reporting-examples) - Real-world ministry scenarios and solutions
# Planning Center Groups Data Model
Source: https://docs.getparable.io/planning-center/groups/data-model
Complete reference for Planning Center Groups tables in Parable: groups, memberships, group types, events, attendance, and how they relate.
This document provides complete documentation of the Planning Center Groups data model in Parable, including all tables, fields, and relationships.
## Overview
The Groups module contains **18 entity tables** and **10 relationship tables** supporting small groups, classes, teams, group events, attendance tracking, enrollment management, and event RSVPs.
## Visual Data Model
The diagram below shows the core entities and their relationships in the Groups module. Use it as a visual reference while exploring the detailed table definitions below.
### Core Entity Relationships
[Open diagram in new tab →](/diagrams/planning-center/groups-data-model-01.svg)
### Key Relationships Explained
**Group Structure:**
* `GROUP_TYPE` categorizes groups (small groups, classes, teams, etc.)
* `GROUP`s are organized by type with flexible hierarchies
* `MEMBERSHIP`s link people to groups with roles (member, leader, owner)
**Enrollment Process:**
* `ENROLLMENT` tracks join requests and approvals
* Separate from `MEMBERSHIP` to handle pending/requested states
* Once approved, becomes an active `MEMBERSHIP`
**Event Management:**
* Groups host `EVENT`s (meetings, activities, gatherings)
* `ATTENDANCE` tracks who actually attended
* `RSVP`s track who plans to attend
* `EVENT_NOTE`s provide additional context
**Generic Relationship Pattern:**
* Location association via `groups_groups_relationships` (relationship\_type: `Location`)
* Campus linkage via `groups_groups_relationships` (relationship\_type: `Campus`)
* Resource assignments via `groups_groups_relationships`
**Tagging System:**
* `TAG_GROUP`s organize related tags
* `TAG`s enable flexible categorization across groups
* Applied via relationship tables
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Groups module are in the `planning_center` schema. You MUST prefix all table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.groups_groups`
❌ INCORRECT: `SELECT * FROM groups_groups`
### Row Level Security (RLS)
This database uses Row Level Security (RLS) to automatically filter data based on:
* **tenant\_organization\_id**: You only see data for your current organization
* **system\_status**: You only see 'active' records by default
**DO NOT add these filters to your WHERE clause** - they are applied automatically:
* ❌ `WHERE tenant_organization_id = 1` (unnecessary)
* ❌ `WHERE system_status = 'active'` (unnecessary)
The RLS policies ensure you only access data you're authorized to see, making these filters redundant and potentially causing performance issues.
## Core Tables Overview
### Primary Entity Tables
* `groups_groups` - Small groups, classes, and teams
* `groups_people` - People who can join groups
* `groups_memberships` - Connections between people and groups
* `groups_events` - Group meetings and gatherings
* `groups_attendances` - Event attendance records
* `groups_rsvps` - Event RSVP responses from group members
* `groups_group_types` - Categories for organizing groups
* `groups_locations` - Physical meeting places
* `groups_enrollments` - Sign-up and registration management
### Supporting Entity Tables
* `groups_campuses` - Campus locations
* `groups_campus_groups` - Links between campuses and groups
* `groups_event_notes` - Notes for events
* `groups_group_applications` - Applications to join groups
* `groups_organizations` - Organization settings
* `groups_owners` - Group ownership information
* `groups_resources` - Group resources
* `groups_tags` - Labels for group characteristics
* `groups_tag_groups` - Tag groupings
### Relationship Tables
* `groups_groups_relationships` - Links groups to other entities
* `groups_memberships_relationships` - Links memberships to related entities
* `groups_events_relationships` - Links events to related entities
* `groups_attendances_relationships` - Links attendances to related entities
* `groups_rsvps_relationships` - Links RSVPs to events, groups, and people
* `groups_enrollments_relationships` - Links enrollments to related entities
* `groups_group_applications_relationships` - Links applications to related entities
* `groups_event_notes_relationships` - Links event notes to related entities
* `groups_resources_relationships` - Links resources to related entities
* `groups_tags_relationships` - Links tags to related entities
## Table Definitions
### groups\_groups
Small groups, classes, teams, and other group entities.
| Column | Type | Description |
| ------------------------------------ | ------------- | ---------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `group_id` | VARCHAR(64) | Planning Center group ID |
| `archived_at` | TIMESTAMP | When group was archived (NULL = active) |
| `chat_enabled` | BOOLEAN | Whether group chat is enabled |
| `contact_email` | VARCHAR(255) | Group contact email |
| `created_at` | TIMESTAMP | When group was created |
| `description` | TEXT | Group description |
| `description_as_plain_text` | TEXT | Plain text version of group description |
| `events_listed` | BOOLEAN | Whether events are listed publicly |
| `events_visibility` | VARCHAR(255) | Event visibility setting |
| `header_image` | JSONB | Group header image data |
| `leaders_can_search_people_database` | BOOLEAN | Leader permission setting |
| `listed` | BOOLEAN | Whether the group is publicly listed |
| `location_type_preference` | VARCHAR(255) | 'physical' or 'virtual' |
| `members_are_confidential` | BOOLEAN | Whether member list is kept confidential |
| `memberships_count` | INTEGER | Current member count |
| `name` | VARCHAR(255) | Group name |
| `public_church_center_web_url` | VARCHAR(2048) | Public group URL |
| `schedule` | VARCHAR(255) | Meeting schedule description |
| `updated_at` | TIMESTAMP | When group was last updated in Planning Center |
| `virtual_location_url` | VARCHAR(2048) | Online meeting URL |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status: 'active', 'transferring', 'stale' |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_people
People who can participate in groups.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_id` | VARCHAR(64) | Planning Center person ID |
| `permissions` | VARCHAR(50) | Person's permission level |
| `created_at` | TIMESTAMP | When person was added to Groups |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_memberships
Connections between people and groups with roles.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `membership_id` | VARCHAR(64) | Planning Center membership ID |
| `joined_at` | TIMESTAMP | When person joined group |
| `role` | VARCHAR(255) | 'member' or 'leader' |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Note**: Group and person associations are stored in the `groups_memberships_relationships` table (relationship\_type: `Group`, `Person`). Use that table to join memberships to groups or people.
### groups\_events
Group meetings, gatherings, and activities.
| Column | Type | Description |
| ----------------------------- | ------------- | ---------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_id` | VARCHAR(64) | Planning Center event ID |
| `attendance_requests_enabled` | BOOLEAN | Requesting attendance responses |
| `automated_reminder_enabled` | BOOLEAN | Auto-reminders enabled |
| `canceled` | BOOLEAN | Cancellation status |
| `canceled_at` | TIMESTAMP | When event was canceled |
| `created_at` | TIMESTAMP | When event was created in Planning Center |
| `description` | TEXT | Event description |
| `ends_at` | TIMESTAMP | Event end time |
| `image` | VARCHAR(2048) | Event image URL |
| `location_type_preference` | VARCHAR(255) | 'physical' or 'virtual' |
| `multi_day` | BOOLEAN | Spans multiple days |
| `name` | VARCHAR(255) | Event name |
| `reminders_sent` | BOOLEAN | Reminders have been sent |
| `reminders_sent_at` | TIMESTAMP | When reminders were sent |
| `repeating` | BOOLEAN | Recurring event |
| `starts_at` | TIMESTAMP | Event start time |
| `updated_at` | TIMESTAMP | When event was last updated in Planning Center |
| `virtual_location_url` | VARCHAR(2048) | Online meeting URL |
| `visitors_count` | INTEGER | Number of visitors |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_attendances
Records of who attended which events.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `attendance_id` | VARCHAR(64) | Planning Center attendance ID |
| `attended` | BOOLEAN | Whether person attended |
| `role` | VARCHAR(255) | Person's role at event |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Note**: Event and person associations are stored in the `groups_attendances_relationships` table (relationship\_type: `Event`, `Person`). Use that table to join attendances to events or people.
### groups\_rsvps
RSVP responses for group events, tracking whether people plan to attend.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `rsvp_id` | VARCHAR(64) | Planning Center RSVP ID |
| `response` | VARCHAR(255) | `'yes'`, `'no'`, `'maybe'`, `'awaiting_response'` (no reply yet — the most common value), or `'not_sent'` |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Note**: RSVPs are linked to Events, Groups, and People through the `groups_rsvps_relationships` table. Use relationship joins to access the event, group, or person associated with each RSVP.
### groups\_group\_types
Categories for organizing and configuring groups.
| Column | Type | Description |
| ------------------------------ | ------------- | ---------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `group_type_id` | VARCHAR(64) | Planning Center group type ID |
| `church_center_visible` | BOOLEAN | Visible in Church Center |
| `church_center_map_visible` | BOOLEAN | Show on Church Center map |
| `color` | VARCHAR(32) | Display color |
| `default_group_settings` | JSONB | Default settings for groups of this type |
| `description` | TEXT | Type description |
| `name` | TEXT | Type name |
| `position` | INTEGER | Sort order |
| `public_church_center_web_url` | VARCHAR(2048) | Public URL for this group type |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_locations
Physical locations where groups meet.
| Column | Type | Description |
| ------------------------ | ---------------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `location_id` | VARCHAR(64) | Planning Center location ID |
| `display_preference` | VARCHAR(100) | How to display location |
| `full_formatted_address` | VARCHAR(500) | Complete address |
| `latitude` | DOUBLE PRECISION | GPS latitude |
| `longitude` | DOUBLE PRECISION | GPS longitude |
| `name` | VARCHAR(255) | Location name |
| `radius` | INTEGER | Coverage radius |
| `strategy` | VARCHAR(100) | Location strategy |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_enrollments
Group enrollment and registration management.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `enrollment_id` | VARCHAR(64) | Planning Center enrollment ID |
| `auto_closed` | BOOLEAN | Automatically closed |
| `auto_closed_reason` | TEXT | Why auto-closed |
| `date_limit` | TEXT | Enrollment deadline |
| `date_limit_reached` | BOOLEAN | Past deadline |
| `member_limit` | INTEGER | Maximum members allowed |
| `member_limit_reached` | BOOLEAN | At capacity |
| `status` | TEXT | Enrollment status |
| `strategy` | TEXT | Enrollment strategy |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_tags
Labels for categorizing and filtering groups.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `tag_id` | VARCHAR(64) | Planning Center tag ID |
| `name` | VARCHAR(255) | Tag name |
| `position` | INTEGER | Sort order |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_tag\_groups
Groupings for organizing tags.
| Column | Type | Description |
| -------------------------- | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `tag_group_id` | VARCHAR(64) | Planning Center tag group ID |
| `display_publicly` | BOOLEAN | Show publicly |
| `multiple_options_enabled` | BOOLEAN | Allow multiple selections |
| `name` | VARCHAR(255) | Group name |
| `position` | INTEGER | Sort order |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_campuses
Campus locations for multi-site organizations.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `campus_id` | VARCHAR(64) | Planning Center campus ID |
| `name` | VARCHAR(255) | Campus name |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_group\_applications
Applications to join groups requiring approval.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `group_application_id` | VARCHAR(64) | Planning Center application ID |
| `applied_at` | TIMESTAMP | When application was submitted |
| `message` | TEXT | Application message |
| `status` | VARCHAR(100) | Application status |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_event\_notes
Notes and annotations for events.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_note_id` | VARCHAR(64) | Planning Center event note ID |
| `body` | TEXT | Note content |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Note**: Event and person associations are stored in the `groups_event_notes_relationships` table. Use that table to join event notes to events or people.
### groups\_organizations
Organization configuration and settings.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `organization_id` | VARCHAR(64) | Planning Center organization ID |
| `name` | VARCHAR(255) | Organization name |
| `time_zone` | VARCHAR(255) | Organization time zone |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_owners
Group ownership and management information.
| Column | Type | Description |
| ------------------------ | ------------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `owner_id` | VARCHAR(64) | Planning Center owner ID |
| `avatar_url` | VARCHAR(2048) | Owner avatar image URL |
| `first_name` | VARCHAR(255) | Owner first name |
| `last_name` | VARCHAR(255) | Owner last name |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### groups\_resources
Resources associated with groups.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_id` | VARCHAR(64) | Planning Center resource ID |
| `description` | TEXT | Resource description |
| `last_updated` | TIMESTAMP | When resource was last updated |
| `name` | VARCHAR(255) | Resource name |
| `type` | VARCHAR(50) | Resource type |
| `visibility` | VARCHAR(50) | Resource visibility |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Note**: Group type associations are stored in the `groups_resources_relationships` table. Use that table to join resources to group types.
### groups\_campus\_groups
Links between campuses and groups.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `group_id` | VARCHAR(64) | Associated group ID (direct reference) |
| `campus_id` | VARCHAR(64) | Associated campus ID (direct reference) |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
## Relationship Tables
### groups\_groups\_relationships
Links groups to related entities like types, locations, and tags.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `group_id` | VARCHAR(64) | Group ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `GroupType` - Links to groups\_group\_types
* `Location` - Links to groups\_locations
* `Tag` - Links to groups\_tags
* `Enrollment` - Links to groups\_enrollments
* `Campus` - Links to groups\_campuses
### groups\_memberships\_relationships
Links memberships to additional related entities.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `membership_id` | VARCHAR(64) | Membership ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `Group` - Links to groups\_groups
* `Person` - Links to groups\_people
### groups\_events\_relationships
Links events to groups and locations.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_id` | VARCHAR(64) | Event ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `Group` - Links to groups\_groups
* `Location` - Links to groups\_locations
### groups\_attendances\_relationships
Links attendance records to events and people.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `attendance_id` | VARCHAR(64) | Attendance ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `Event` - Links to groups\_events
* `Person` - Links to groups\_people
### groups\_rsvps\_relationships
Links RSVPs to their associated events, groups, and people.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `rsvp_id` | VARCHAR(64) | RSVP ID |
| `relationship_type` | VARCHAR(255) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `Event` - Links to groups\_events
* `Group` - Links to groups\_groups
* `Person` - Links to groups\_people
### groups\_enrollments\_relationships
Links enrollments to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `enrollment_id` | VARCHAR(64) | Enrollment ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `Group` - Links to groups\_groups
* `Person` - Links to groups\_people
### groups\_group\_applications\_relationships
Links group applications to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `group_application_id` | VARCHAR(64) | Group application ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `Group` - Links to groups\_groups
* `Person` - Links to groups\_people
### groups\_event\_notes\_relationships
Links event notes to related entities like events and people.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `event_note_id` | VARCHAR(64) | Event note ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `Event` - Links to groups\_events
* `Person` - Links to groups\_people
### groups\_resources\_relationships
Links resources to related entities like group types.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `resource_id` | VARCHAR(64) | Resource ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `GroupType` - Links to groups\_group\_types
### groups\_tags\_relationships
Links tags to related entities like tag groups.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `tag_id` | VARCHAR(64) | Tag ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
Common relationship types:
* `TagGroup` - Links to groups\_tag\_groups
## System Fields
All tables include these system fields for data management:
* `tenant_organization_id` - Multi-tenant organization identifier
* `system_status` - Data lifecycle status:
* `transferring` - Being imported from Planning Center
* `active` - Current active data
* `stale` - Marked for removal
* `system_created_at` - When record was created in Parable
* `system_updated_at` - When record was last updated in Parable
## Common Query Patterns
### Finding a Person's Groups
```sql theme={null}
SELECT
g.name as group_name,
g.description,
m.role,
m.joined_at
FROM planning_center.groups_memberships_relationships mr_person
JOIN planning_center.groups_memberships m
ON m.membership_id = mr_person.membership_id
JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.membership_id = m.membership_id AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_groups g
ON g.group_id = mr_group.relationship_id
WHERE mr_person.relationship_type = 'Person'
AND mr_person.relationship_id = 'PERSON_ID'
AND g.archived_at IS NULL
ORDER BY m.joined_at DESC;
```
### Getting Group Members with Roles
```sql theme={null}
SELECT
g.name as group_name,
p.person_id,
m.role,
m.joined_at
FROM planning_center.groups_groups g
JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
JOIN planning_center.groups_memberships m
ON m.membership_id = mr.membership_id
JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.membership_id = m.membership_id AND mr_person.relationship_type = 'Person'
JOIN planning_center.groups_people p
ON p.person_id = mr_person.relationship_id
WHERE g.group_id = 'GROUP_ID'
ORDER BY m.role DESC, m.joined_at;
```
### Finding Events for a Group
```sql theme={null}
SELECT
e.name as event_name,
e.starts_at,
e.ends_at,
e.location_type_preference,
e.canceled
FROM planning_center.groups_events e
JOIN planning_center.groups_events_relationships er
ON e.event_id = er.event_id
AND er.relationship_type = 'Group'
WHERE er.relationship_id = 'GROUP_ID'
AND e.starts_at >= CURRENT_DATE
ORDER BY e.starts_at;
```
### Tracking Event Attendance
```sql theme={null}
SELECT
e.name as event_name,
e.starts_at,
COUNT(CASE WHEN a.attended = true THEN 1 END) as attended_count,
COUNT(DISTINCT a.attendance_id) as total_invited
FROM planning_center.groups_events e
LEFT JOIN planning_center.groups_attendances_relationships ar
ON ar.relationship_type = 'Event' AND ar.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = ar.attendance_id
GROUP BY e.event_id, e.name, e.starts_at
ORDER BY e.starts_at DESC;
```
### Event RSVPs Summary
```sql theme={null}
SELECT
e.name as event_name,
e.starts_at,
COUNT(CASE WHEN r.response = 'yes' THEN 1 END) as yes_count,
COUNT(CASE WHEN r.response = 'no' THEN 1 END) as no_count,
COUNT(CASE WHEN r.response = 'maybe' THEN 1 END) as maybe_count,
-- Most RSVP rows are still 'awaiting_response'; count them so the
-- buckets add up to the total.
COUNT(CASE WHEN r.response = 'awaiting_response' THEN 1 END) as awaiting_count,
COUNT(CASE WHEN r.response = 'not_sent' THEN 1 END) as not_sent_count,
COUNT(*) as total_rsvps
FROM planning_center.groups_events e
JOIN planning_center.groups_rsvps_relationships er
ON e.event_id = er.relationship_id
AND er.relationship_type = 'Event'
JOIN planning_center.groups_rsvps r
ON er.rsvp_id = r.rsvp_id
WHERE e.starts_at >= CURRENT_DATE
GROUP BY e.event_id, e.name, e.starts_at
ORDER BY e.starts_at;
```
### Finding RSVPs for a Person
```sql theme={null}
SELECT
e.name as event_name,
e.starts_at,
g.name as group_name,
r.response
FROM planning_center.groups_rsvps r
JOIN planning_center.groups_rsvps_relationships er
ON r.rsvp_id = er.rsvp_id
AND er.relationship_type = 'Event'
JOIN planning_center.groups_events e
ON er.relationship_id = e.event_id
JOIN planning_center.groups_rsvps_relationships gr
ON r.rsvp_id = gr.rsvp_id
AND gr.relationship_type = 'Group'
JOIN planning_center.groups_groups g
ON gr.relationship_id = g.group_id
JOIN planning_center.groups_rsvps_relationships pr
ON r.rsvp_id = pr.rsvp_id
AND pr.relationship_type = 'Person'
WHERE pr.relationship_id = 'PERSON_ID'
AND e.starts_at >= CURRENT_DATE
ORDER BY e.starts_at;
```
### Groups by Type
```sql theme={null}
SELECT
gt.name as group_type,
COUNT(DISTINCT g.group_id) as group_count,
SUM(g.memberships_count) as total_members
FROM planning_center.groups_group_types gt
LEFT JOIN planning_center.groups_groups_relationships gr
ON gt.group_type_id = gr.relationship_id
AND gr.relationship_type = 'GroupType'
LEFT JOIN planning_center.groups_groups g
ON gr.group_id = g.group_id
AND g.archived_at IS NULL
GROUP BY gt.group_type_id, gt.name, gt.position
ORDER BY gt.position;
```
## Data Integrity Rules
1. **Schema Qualification**: Always use `planning_center.` prefix for all table references
2. **Row Level Security**: RLS automatically handles multi-tenancy and status filtering - do not add manual filters
3. **Monetary Values**: Group event fees or donations stored in cents should be divided by 100.0 for display
4. **Archived Groups**: Use `archived_at IS NULL` or `archived_at` comparisons to control visibility instead of checking `system_status`
5. **Direct ID Columns**: Core tables such as `groups_groups` and `groups_memberships` expose direct IDs for performance-sensitive joins
## Common Mistakes to Avoid
1. **Missing Schema Prefix**
* ❌ `FROM groups_groups`
* ✅ `FROM planning_center.groups_groups`
2. **Adding Redundant RLS Filters**
* ❌ `WHERE tenant_organization_id = 1 AND system_status = 'active'`
* ✅ Trust RLS to handle this automatically
3. **Joining Without Schema**
* ❌ `JOIN groups_memberships m ON ...`
* ✅ `JOIN planning_center.groups_memberships m ON ...`
## Performance Considerations
1. **Indexes**: All tables have optimized indexes on:
* Primary keys and entity IDs
* Join columns and foreign keys
* Date columns for time-based queries
2. **Query Optimization**:
* Always use the `planning_center.` schema prefix
* RLS handles tenant and status filtering automatically
* Filter archived groups or attendance flags when relevant
* Consider CTEs for complex multi-join queries
* Join through the `*_relationships` tables — entity tables carry no foreign-key columns
## Data Types and Conventions
### Location Preferences
* `physical` - In-person meetings
* `virtual` - Online meetings
### Roles
* `member` - Regular group member
* `leader` - Group leader with additional permissions
### RSVP Responses
* `yes` - Person plans to attend
* `no` - Person will not attend
* `maybe` - Person is undecided
### Application Status
* `pending` - Awaiting review
* `approved` - Accepted into group
* `rejected` - Not accepted
## Next Steps
* Return to [Overview](/planning-center/groups/overview) for high-level understanding
* Review [Basic Queries](/planning-center/groups/basic-queries) for simple examples
* Check [Advanced Queries](/planning-center/groups/advanced-queries) for complex analysis
* See [Reporting Examples](/planning-center/groups/reporting-examples) for production-ready reports
# Planning Center Groups SQL Queries
Source: https://docs.getparable.io/planning-center/groups/overview
Query Planning Center Groups data with SQL to understand small-group participation, track membership changes, and measure group event attendance.
## Build Stronger Community Through Data-Driven Group Ministry
Your small groups are where life change happens. With Parable's SQL access to Planning Center Groups data, you can understand participation patterns, identify growth opportunities, and ensure no one falls through the cracks in your community.
## Quick Start
Ready to explore your groups data? Here's your first query to see your active groups and their membership:
```sql theme={null}
-- See your 10 most active groups with member counts
SELECT
g.group_id,
g.name,
g.description,
g.memberships_count,
g.schedule,
g.location_type_preference,
g.created_at
FROM planning_center.groups_groups g
WHERE g.archived_at IS NULL -- Only active groups
ORDER BY g.memberships_count DESC NULLS LAST
LIMIT 10;
```
## What You Can Do With Groups Queries
### 👥 Understand Group Participation
* Track membership growth and retention
* Identify groups that need more members
* Find people not yet connected to a group
* Monitor leadership coverage across groups
### 📊 Measure Engagement
* Analyze attendance patterns and trends
* Identify highly engaged vs occasional participants
* Track event participation rates
* Measure group meeting consistency
### 🎯 Strategic Planning
* Evaluate group types and their effectiveness
* Plan new groups based on participation gaps
* Optimize group locations and meeting times
* Balance group sizes for better community
### 📈 Generate Leadership Reports
* Track leader-to-member ratios
* Identify potential new leaders
* Monitor group health metrics
* Create dashboard views for ministry leaders
## Available Tables
Your Planning Center Groups data is organized into these main tables:
| Table | What It Contains | Key Use Cases |
| -------------------- | --------------------------- | ------------------------------------------- |
| `groups_groups` | Group information | Group details, schedules, settings |
| `groups_memberships` | Member-to-group connections | Who's in which group, roles, join dates |
| `groups_people` | People in the Groups system | Member profiles, permissions |
| `groups_events` | Group meetings and events | Meeting schedules, locations, cancellations |
| `groups_attendances` | Event attendance records | Who attended what, attendance tracking |
| `groups_group_types` | Categories for groups | Small groups, classes, teams, etc. |
| `groups_locations` | Physical meeting locations | Address details, venue information |
| `groups_tags` | Labels for groups | Group characteristics, ministries |
| `groups_enrollments` | Sign-ups and registrations | Future group enrollments, waitlists |
## Understanding Relationships
Parable stores Planning Center relationships in separate tables to maintain data integrity. This means connections between entities are stored in dedicated relationship tables:
* `groups_groups_relationships` - Links groups to types, locations, tags, etc.
* `groups_memberships_relationships` - Links memberships to groups and people
* `groups_events_relationships` - Links events to groups and locations
* `groups_attendances_relationships` - Links attendances to events and people
We'll show you exactly how to use these relationship tables in our query examples!
## Key Concepts
### Groups vs Memberships vs People
* **Groups** are the small groups, classes, or teams in your church
* **Memberships** connect people to groups with specific roles (member, leader)
* **People** are individuals who can be members of multiple groups
### Events vs Attendances
* **Events** are scheduled group meetings or activities
* **Attendances** track who actually showed up to each event
### Roles in Groups
Members can have different roles:
* `member` - Regular participant
* `leader` - Group leader with additional permissions
## Next Steps
📚 **New to SQL?** Start with [Basic Queries](/planning-center/groups/basic-queries) for simple, powerful queries you can use today.
🚀 **Ready for More?** Check out [Advanced Queries](/planning-center/groups/advanced-queries) for complex analysis and reporting.
📊 **Need Reports?** See [Reporting Examples](/planning-center/groups/reporting-examples) for complete, production-ready reports.
🔍 **Want Details?** Review the [Data Model](/planning-center/groups/data-model) for complete table documentation.
## Common Questions
### Why do some fields show NULL?
NULL values appear when:
* A group hasn't been archived (`archived_at IS NULL` means active)
* Optional information wasn't provided (like `virtual_location_url`)
* An event hasn't happened yet (`canceled_at IS NULL` means not canceled)
### How do I filter for active groups only?
```sql theme={null}
SELECT group_id, name
FROM planning_center.groups_groups
WHERE archived_at IS NULL; -- active groups haven't been archived
```
### What's the difference between created\_at and system\_created\_at?
* `created_at` - When the group was created in Planning Center
* `system_created_at` - When the data was synced to Parable
* Use `created_at` for ministry metrics
### How do I find a person's groups?
You need to join through the memberships table:
```sql theme={null}
SELECT
g.name as group_name,
m.role,
m.joined_at
FROM planning_center.groups_memberships m
JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.membership_id = m.membership_id AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_groups g
ON g.group_id = mr_group.relationship_id
JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.membership_id = m.membership_id AND mr_person.relationship_type = 'Person'
JOIN planning_center.groups_people p
ON p.person_id = mr_person.relationship_id
WHERE p.person_id = 'YOUR_PERSON_ID'
AND g.archived_at IS NULL;
```
### What does location\_type\_preference mean?
This indicates where the group typically meets:
* `physical` - In-person at a location
* `virtual` - Online meetings
## Tips for Success
1. **Start Simple** - Begin with basic SELECT queries before adding complexity
2. **Use Comments** - Document what your queries do for future reference
3. **Test with LIMIT** - Add `LIMIT 10` while developing queries
4. **Handle NULLs** - Use `IS NULL` or `IS NOT NULL` appropriately
5. **Check Relationships** - Remember to join through relationship tables
## Getting Help
* 🐛 Found an issue? Report it at [github.com/getparable/parable-api/issues](https://github.com/getparable/parable-api/issues)
* 📖 Need more examples? Check our other query guides in this folder
* 💬 Have questions? Reach out to your Parable support team
***
*Your groups data tells the story of community. Let's help you understand it better.*
# Planning Center Groups Report Examples
Source: https://docs.getparable.io/planning-center/groups/reporting-examples
Production-ready Groups reports for ministry leaders: participation summaries, inactive group lists, and attendance trends ready to schedule.
This guide provides complete, production-ready SQL reports for Planning Center Groups data. These reports are designed to be run regularly for leadership meetings, ministry planning, and strategic decision-making.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Groups module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your reports.
✅ CORRECT: `SELECT * FROM planning_center.groups_groups`
❌ INCORRECT: `SELECT * FROM groups_groups`
### Row Level Security (RLS)
Row Level Security automatically enforces:
* **tenant\_organization\_id** – results scoped to your organization
* **system\_status** – active records returned by default
**Skip manual filters for these columns**—RLS already applies them and redundant predicates can suppress data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Direct your filters toward ministry context (archived status, roles, attendance timeframes) while trusting RLS for tenancy and status.
## Executive Dashboard Report
### Weekly Groups Executive Summary
```sql theme={null}
-- Executive summary report for groups ministry leadership
WITH current_week AS (
SELECT
COUNT(DISTINCT g.group_id) as total_active_groups,
SUM(g.memberships_count) as total_members,
COUNT(DISTINCT CASE WHEN g.created_at >= DATE_TRUNC('week', CURRENT_DATE) THEN g.group_id END) as new_groups,
COUNT(DISTINCT e.event_id) as total_events,
COUNT(DISTINCT CASE WHEN e.canceled = true THEN e.event_id END) as canceled_events
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_events_relationships er
ON g.group_id = er.relationship_id
AND er.relationship_type = 'Group'
LEFT JOIN planning_center.groups_events e
ON er.event_id = e.event_id
AND e.starts_at >= DATE_TRUNC('week', CURRENT_DATE)
AND e.starts_at < DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '1 week'
WHERE g.archived_at IS NULL
),
previous_week AS (
SELECT
COUNT(DISTINCT g.group_id) as total_active_groups,
SUM(g.memberships_count) as total_members,
COUNT(DISTINCT e.event_id) as total_events
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_events_relationships er
ON g.group_id = er.relationship_id
AND er.relationship_type = 'Group'
LEFT JOIN planning_center.groups_events e
ON er.event_id = e.event_id
AND e.starts_at >= DATE_TRUNC('week', CURRENT_DATE) - INTERVAL '1 week'
AND e.starts_at < DATE_TRUNC('week', CURRENT_DATE)
WHERE g.archived_at IS NULL
AND g.created_at < DATE_TRUNC('week', CURRENT_DATE)
),
engagement_metrics AS (
SELECT
COUNT(DISTINCT apr.relationship_id) as unique_attendees,
AVG(CASE WHEN a.attended = true THEN 1.0 ELSE 0 END) * 100 as attendance_rate
FROM planning_center.groups_attendances a
JOIN planning_center.groups_attendances_relationships aer
ON aer.attendance_id = a.attendance_id AND aer.relationship_type = 'Event'
JOIN planning_center.groups_events e
ON e.event_id = aer.relationship_id
LEFT JOIN planning_center.groups_attendances_relationships apr
ON apr.attendance_id = a.attendance_id AND apr.relationship_type = 'Person'
WHERE e.starts_at >= DATE_TRUNC('week', CURRENT_DATE)
)
SELECT
'=== WEEKLY GROUPS EXECUTIVE SUMMARY ===' as report_header,
TO_CHAR(DATE_TRUNC('week', CURRENT_DATE), 'FMMonth DD, YYYY') as week_beginning,
'' as blank1,
'--- GROUP METRICS ---' as section1,
cw.total_active_groups as active_groups,
pw.total_active_groups as active_groups_last_week,
cw.total_active_groups - pw.total_active_groups as group_change,
cw.new_groups as new_groups_this_week,
'' as blank2,
'--- MEMBERSHIP METRICS ---' as section2,
cw.total_members as total_members,
pw.total_members as total_members_last_week,
cw.total_members - pw.total_members as member_change,
ROUND(cw.total_members::NUMERIC / NULLIF(cw.total_active_groups, 0), 1) as avg_group_size,
'' as blank3,
'--- EVENT METRICS ---' as section3,
cw.total_events as events_this_week,
cw.canceled_events as canceled_events,
ROUND((cw.total_events - cw.canceled_events)::NUMERIC / NULLIF(cw.total_active_groups, 0), 1) as avg_events_per_group,
'' as blank4,
'--- ENGAGEMENT METRICS ---' as section4,
em.unique_attendees as unique_attendees_this_week,
ROUND(em.attendance_rate, 1) as attendance_percentage
FROM current_week cw, previous_week pw, engagement_metrics em;
```
### Monthly Group Health Report
```sql theme={null}
-- Monthly comprehensive group health assessment
WITH group_health AS (
SELECT
g.group_id,
g.name,
g.memberships_count,
g.location_type_preference,
DATE_PART('month', AGE(CURRENT_DATE, g.created_at)) as months_active,
COUNT(DISTINCT mr.membership_id) as actual_members,
COUNT(DISTINCT CASE WHEN m.role = 'leader' THEN mr.membership_id END) as leaders,
COUNT(DISTINCT e.event_id) as events_last_month,
AVG(CASE WHEN a.attended = true THEN 1.0 ELSE 0 END) * 100 as avg_attendance_rate,
MAX(e.starts_at) as last_event_date,
CURRENT_DATE - MAX(e.starts_at)::DATE as days_since_last_event
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m
ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_id = g.group_id
AND er.relationship_type = 'Group'
LEFT JOIN planning_center.groups_events e
ON e.event_id = er.event_id
AND e.starts_at >= CURRENT_DATE - INTERVAL '30 days'
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = aer.attendance_id
WHERE g.archived_at IS NULL
GROUP BY g.group_id, g.name, g.memberships_count, g.location_type_preference, g.created_at
),
health_categories AS (
SELECT
name,
memberships_count,
actual_members,
leaders,
events_last_month,
ROUND(avg_attendance_rate, 1) as attendance_rate,
days_since_last_event,
months_active,
CASE
WHEN days_since_last_event > 30 OR days_since_last_event IS NULL THEN '🚨 Inactive'
WHEN leaders = 0 THEN '⚠️ No Leader'
WHEN actual_members < 3 THEN '⚠️ Too Small'
WHEN actual_members > 15 THEN '⚠️ Consider Splitting'
WHEN avg_attendance_rate < 50 THEN '⚠️ Low Attendance'
WHEN events_last_month = 0 THEN '⚠️ No Recent Events'
ELSE '✓ Healthy'
END as health_status
FROM group_health
)
SELECT
health_status,
COUNT(*) as group_count,
STRING_AGG(name, ', ' ORDER BY name) as groups
FROM health_categories
GROUP BY health_status
ORDER BY
CASE health_status
WHEN '🚨 Inactive' THEN 1
WHEN '⚠️ No Leader' THEN 2
WHEN '⚠️ Too Small' THEN 3
WHEN '⚠️ Consider Splitting' THEN 4
WHEN '⚠️ Low Attendance' THEN 5
WHEN '⚠️ No Recent Events' THEN 6
ELSE 7
END;
```
## Member Engagement Reports
### Member Participation Analysis
```sql theme={null}
-- Comprehensive member engagement tracking across all groups
WITH member_activity AS (
SELECT
p.person_id,
COUNT(DISTINCT mr_group.relationship_id) as groups_joined,
COUNT(DISTINCT CASE WHEN m.role = 'leader' THEN mr_group.relationship_id END) as groups_leading,
MIN(m.joined_at) as first_group_joined,
COUNT(DISTINCT e.event_id) as events_invited,
COUNT(DISTINCT CASE WHEN a.attended = true THEN e.event_id END) as events_attended,
MAX(e.starts_at) as last_event_date
FROM planning_center.groups_people p
LEFT JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.relationship_id = p.person_id AND mr_person.relationship_type = 'Person'
LEFT JOIN planning_center.groups_memberships m
ON m.membership_id = mr_person.membership_id
LEFT JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.membership_id = m.membership_id AND mr_group.relationship_type = 'Group'
LEFT JOIN planning_center.groups_groups g
ON g.group_id = mr_group.relationship_id
AND g.archived_at IS NULL
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_id = g.group_id
AND er.relationship_type = 'Group'
LEFT JOIN planning_center.groups_events e
ON e.event_id = er.event_id
AND e.starts_at >= CURRENT_DATE - INTERVAL '90 days'
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = aer.attendance_id
GROUP BY p.person_id
),
engagement_categories AS (
SELECT
person_id,
groups_joined,
groups_leading,
events_invited,
events_attended,
CASE
WHEN events_invited > 0 THEN
ROUND(events_attended::NUMERIC / events_invited * 100, 1)
ELSE 0
END as attendance_percentage,
DATE_PART('month', AGE(CURRENT_DATE, first_group_joined)) as months_in_groups,
CURRENT_DATE - last_event_date::DATE as days_since_last_event,
CASE
WHEN groups_joined = 0 THEN 'Not Connected'
WHEN events_attended = 0 AND events_invited > 0 THEN 'Inactive'
WHEN events_invited > 0 AND (events_attended::NUMERIC / events_invited) < 0.25 THEN 'Low Engagement'
WHEN events_invited > 0 AND (events_attended::NUMERIC / events_invited) < 0.5 THEN 'Moderate Engagement'
WHEN groups_leading > 0 THEN 'Leader'
ELSE 'Highly Engaged'
END as engagement_level
FROM member_activity
)
SELECT
engagement_level,
COUNT(*) as member_count,
ROUND(AVG(groups_joined), 1) as avg_groups_per_member,
ROUND(AVG(attendance_percentage), 1) as avg_attendance_rate,
ROUND(AVG(months_in_groups)::NUMERIC, 0) as avg_months_in_groups
FROM engagement_categories
GROUP BY engagement_level
ORDER BY
CASE engagement_level
WHEN 'Not Connected' THEN 1
WHEN 'Inactive' THEN 2
WHEN 'Low Engagement' THEN 3
WHEN 'Moderate Engagement' THEN 4
WHEN 'Highly Engaged' THEN 5
WHEN 'Leader' THEN 6
END;
```
### Leadership Development Pipeline
```sql theme={null}
-- Identify potential leaders based on engagement and participation
WITH member_metrics AS (
SELECT
p.person_id,
mr_group.relationship_id as group_id,
g.name as group_name,
m.role,
m.joined_at,
DATE_PART('month', AGE(CURRENT_DATE, m.joined_at)) as months_in_group,
COUNT(DISTINCT e.event_id) as events_count,
COUNT(DISTINCT CASE WHEN a.attended = true THEN e.event_id END) as events_attended,
AVG(CASE WHEN a.attended = true THEN 1.0 ELSE 0 END) as attendance_rate
FROM planning_center.groups_people p
JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.relationship_id = p.person_id AND mr_person.relationship_type = 'Person'
JOIN planning_center.groups_memberships m
ON m.membership_id = mr_person.membership_id
JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.membership_id = m.membership_id AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_groups g
ON g.group_id = mr_group.relationship_id
AND g.archived_at IS NULL
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_id = g.group_id
AND er.relationship_type = 'Group'
LEFT JOIN planning_center.groups_events e
ON e.event_id = er.event_id
AND e.starts_at >= CURRENT_DATE - INTERVAL '6 months'
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = aer.attendance_id
WHERE m.role = 'member' -- Only look at current members, not leaders
GROUP BY p.person_id, mr_group.relationship_id, g.name, m.role, m.joined_at
HAVING COUNT(DISTINCT e.event_id) >= 5 -- Attended at least 5 events
),
leadership_candidates AS (
SELECT
person_id,
group_id,
group_name,
months_in_group,
events_attended,
ROUND(attendance_rate * 100, 1) as attendance_percentage,
CASE
WHEN months_in_group >= 12 AND attendance_rate >= 0.8 THEN 'Ready Now'
WHEN months_in_group >= 6 AND attendance_rate >= 0.7 THEN 'Ready Soon'
WHEN months_in_group >= 3 AND attendance_rate >= 0.6 THEN 'Developing'
ELSE 'Watch'
END as leadership_readiness
FROM member_metrics
WHERE attendance_rate >= 0.6 -- At least 60% attendance
)
SELECT
leadership_readiness,
COUNT(DISTINCT person_id) as candidate_count,
ROUND(AVG(months_in_group)::NUMERIC, 1) as avg_months_in_group,
ROUND(AVG(attendance_percentage), 1) as avg_attendance,
STRING_AGG(DISTINCT group_name, ', ' ORDER BY group_name) as groups_with_candidates
FROM leadership_candidates
GROUP BY leadership_readiness
ORDER BY
CASE leadership_readiness
WHEN 'Ready Now' THEN 1
WHEN 'Ready Soon' THEN 2
WHEN 'Developing' THEN 3
ELSE 4
END;
```
## Group Type Analysis Reports
### Group Type Performance Comparison
```sql theme={null}
-- Compare performance metrics across different group types
WITH type_metrics AS (
SELECT
gt.group_type_id,
gt.name as type_name,
gt.color,
gt.church_center_visible,
COUNT(DISTINCT g.group_id) as group_count,
SUM(g.memberships_count) as total_members,
AVG(g.memberships_count) as avg_group_size,
COUNT(DISTINCT CASE WHEN g.created_at >= CURRENT_DATE - INTERVAL '90 days' THEN g.group_id END) as new_groups_90_days,
COUNT(DISTINCT e.event_id) as total_events,
AVG(CASE WHEN a.attended = true THEN 1.0 ELSE 0 END) * 100 as avg_attendance_rate
FROM planning_center.groups_group_types gt
LEFT JOIN planning_center.groups_groups_relationships gr
ON gt.group_type_id = gr.relationship_id
AND gr.relationship_type = 'GroupType'
LEFT JOIN planning_center.groups_groups g
ON gr.group_id = g.group_id
AND g.archived_at IS NULL
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_id = g.group_id
AND er.relationship_type = 'Group'
LEFT JOIN planning_center.groups_events e
ON e.event_id = er.event_id
AND e.starts_at >= CURRENT_DATE - INTERVAL '30 days'
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = aer.attendance_id
GROUP BY gt.group_type_id, gt.name, gt.color, gt.church_center_visible
)
SELECT
type_name,
group_count,
total_members,
ROUND(avg_group_size, 1) as avg_group_size,
new_groups_90_days,
ROUND(total_events::NUMERIC / NULLIF(group_count, 0), 1) as avg_events_per_group,
ROUND(avg_attendance_rate, 1) as attendance_rate,
CASE
WHEN church_center_visible THEN 'Public'
ELSE 'Private'
END as visibility,
CASE
WHEN new_groups_90_days > 0 THEN '📈 Growing'
WHEN avg_attendance_rate < 50 THEN '⚠️ Low Engagement'
WHEN avg_group_size < 5 THEN '⚠️ Small Groups'
ELSE '✓ Stable'
END as status
FROM type_metrics
WHERE group_count > 0
ORDER BY total_members DESC;
```
## Event Management Reports
### Weekly Event Schedule Report
```sql theme={null}
-- Comprehensive weekly event schedule with attendance tracking
WITH week_events AS (
SELECT
e.event_id,
e.name as event_name,
e.description,
e.starts_at,
e.ends_at,
e.location_type_preference,
e.virtual_location_url,
e.canceled,
g.name as group_name,
g.memberships_count as group_size,
COUNT(DISTINCT apr.relationship_id) as expected_attendees,
COUNT(DISTINCT CASE WHEN a.attended = true THEN apr.relationship_id END) as actual_attendees
FROM planning_center.groups_events e
JOIN planning_center.groups_events_relationships er
ON er.event_id = e.event_id
AND er.relationship_type = 'Group'
JOIN planning_center.groups_groups g
ON g.group_id = er.relationship_id
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = aer.attendance_id
LEFT JOIN planning_center.groups_attendances_relationships apr
ON apr.attendance_id = a.attendance_id AND apr.relationship_type = 'Person'
WHERE e.starts_at >= DATE_TRUNC('week', CURRENT_DATE)
AND e.starts_at < DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '1 week'
GROUP BY e.event_id, e.name, e.description, e.starts_at, e.ends_at,
e.location_type_preference, e.virtual_location_url,
e.canceled, g.name, g.memberships_count
)
SELECT
TO_CHAR(starts_at, 'FMDay') as day_of_week,
TO_CHAR(starts_at, 'HH12:MI AM') as time,
event_name,
group_name,
CASE
WHEN canceled THEN '❌ CANCELED'
WHEN location_type_preference = 'virtual' THEN '💻 Online'
WHEN location_type_preference = 'physical' THEN '📍 In Person'
ELSE '🔄 Hybrid'
END as location_info,
group_size as group_members,
expected_attendees as rsvps,
CASE
WHEN starts_at < CURRENT_TIMESTAMP THEN actual_attendees::TEXT
ELSE 'Upcoming'
END as attendance,
CASE
WHEN canceled THEN 'Canceled'
WHEN starts_at < CURRENT_TIMESTAMP AND actual_attendees = 0 THEN '⚠️ No attendance recorded'
WHEN starts_at < CURRENT_TIMESTAMP AND actual_attendees < expected_attendees * 0.5 THEN '⚠️ Low attendance'
WHEN starts_at > CURRENT_TIMESTAMP THEN 'Scheduled'
ELSE '✓ Completed'
END as status
FROM week_events
ORDER BY starts_at;
```
## Growth and Retention Reports
### Quarterly Growth Analysis
```sql theme={null}
-- Track group growth patterns over the last 4 quarters
WITH quarterly_data AS (
SELECT
DATE_TRUNC('quarter', g.created_at) as quarter,
COUNT(DISTINCT g.group_id) as new_groups,
COUNT(DISTINCT CASE WHEN g.archived_at IS NOT NULL THEN g.group_id END) as archived_groups,
COUNT(DISTINCT m.membership_id) as new_memberships,
COUNT(DISTINCT mr_person.relationship_id) as new_members
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m
ON m.membership_id = mr.membership_id
AND DATE_TRUNC('quarter', m.joined_at) = DATE_TRUNC('quarter', g.created_at)
LEFT JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.membership_id = m.membership_id AND mr_person.relationship_type = 'Person'
WHERE g.created_at >= DATE_TRUNC('quarter', CURRENT_DATE) - INTERVAL '1 year'
GROUP BY DATE_TRUNC('quarter', g.created_at)
),
cumulative_metrics AS (
SELECT
quarter,
TO_CHAR(quarter, 'Q[Q] YYYY') as quarter_label,
new_groups,
archived_groups,
new_memberships,
new_members,
SUM(new_groups - COALESCE(archived_groups, 0)) OVER (ORDER BY quarter) as net_groups_cumulative,
LAG(new_groups, 1) OVER (ORDER BY quarter) as prev_quarter_groups,
LAG(new_members, 1) OVER (ORDER BY quarter) as prev_quarter_members
FROM quarterly_data
)
SELECT
quarter_label,
new_groups,
archived_groups,
new_groups - COALESCE(archived_groups, 0) as net_new_groups,
new_members,
new_memberships,
ROUND(new_memberships::NUMERIC / NULLIF(new_groups, 0), 1) as avg_members_per_new_group,
CASE
WHEN prev_quarter_groups > 0 THEN
ROUND(((new_groups - prev_quarter_groups)::NUMERIC / prev_quarter_groups) * 100, 1)
ELSE NULL
END as group_growth_rate,
CASE
WHEN prev_quarter_members > 0 THEN
ROUND(((new_members - prev_quarter_members)::NUMERIC / prev_quarter_members) * 100, 1)
ELSE NULL
END as member_growth_rate,
net_groups_cumulative as total_active_groups
FROM cumulative_metrics
ORDER BY quarter DESC;
```
### Member Retention Cohort Analysis
```sql theme={null}
-- Analyze member retention by cohort (when they joined their first group)
WITH member_cohorts AS (
SELECT
p.person_id,
DATE_TRUNC('month', MIN(m.joined_at)) as cohort_month,
MIN(m.joined_at) as first_joined,
MAX(e.starts_at) as last_activity,
COUNT(DISTINCT mr_group.relationship_id) as total_groups_joined,
COUNT(DISTINCT CASE WHEN g.archived_at IS NULL THEN mr_group.relationship_id END) as active_groups
FROM planning_center.groups_people p
JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.relationship_id = p.person_id AND mr_person.relationship_type = 'Person'
JOIN planning_center.groups_memberships m
ON m.membership_id = mr_person.membership_id
JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.membership_id = m.membership_id AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_groups g
ON g.group_id = mr_group.relationship_id
LEFT JOIN planning_center.groups_attendances_relationships apr
ON apr.relationship_id = p.person_id AND apr.relationship_type = 'Person'
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = apr.attendance_id
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.attendance_id = a.attendance_id AND aer.relationship_type = 'Event'
LEFT JOIN planning_center.groups_events e
ON e.event_id = aer.relationship_id
GROUP BY p.person_id
),
retention_analysis AS (
SELECT
TO_CHAR(cohort_month, 'Mon YYYY') as cohort,
COUNT(DISTINCT person_id) as cohort_size,
COUNT(DISTINCT CASE
WHEN last_activity >= cohort_month + INTERVAL '1 month' THEN person_id
END) as retained_1_month,
COUNT(DISTINCT CASE
WHEN last_activity >= cohort_month + INTERVAL '3 months' THEN person_id
END) as retained_3_months,
COUNT(DISTINCT CASE
WHEN last_activity >= cohort_month + INTERVAL '6 months' THEN person_id
END) as retained_6_months,
COUNT(DISTINCT CASE
WHEN active_groups > 0 THEN person_id
END) as currently_active,
AVG(total_groups_joined) as avg_groups_per_member
FROM member_cohorts
WHERE cohort_month >= CURRENT_DATE - INTERVAL '1 year'
GROUP BY cohort_month
)
SELECT
cohort,
cohort_size,
ROUND(retained_1_month::NUMERIC / NULLIF(cohort_size, 0) * 100, 1) as month_1_retention,
ROUND(retained_3_months::NUMERIC / NULLIF(cohort_size, 0) * 100, 1) as month_3_retention,
ROUND(retained_6_months::NUMERIC / NULLIF(cohort_size, 0) * 100, 1) as month_6_retention,
ROUND(currently_active::NUMERIC / NULLIF(cohort_size, 0) * 100, 1) as currently_active_pct,
ROUND(avg_groups_per_member, 1) as avg_groups_joined
FROM retention_analysis
ORDER BY cohort DESC;
```
## Location Analysis Report
### Geographic Distribution and Optimization
```sql theme={null}
-- Analyze group distribution by location for planning purposes
WITH location_stats AS (
SELECT
l.location_id,
l.name as location_name,
l.full_formatted_address,
l.latitude,
l.longitude,
COUNT(DISTINCT g.group_id) as groups_at_location,
SUM(g.memberships_count) as total_members,
COUNT(DISTINCT e.event_id) as events_last_month,
AVG(CASE WHEN a.attended = true THEN 1.0 ELSE 0 END) * 100 as avg_attendance_rate
FROM planning_center.groups_locations l
LEFT JOIN planning_center.groups_groups_relationships gr
ON l.location_id = gr.relationship_id
AND gr.relationship_type = 'Location'
LEFT JOIN planning_center.groups_groups g
ON gr.group_id = g.group_id
AND g.archived_at IS NULL
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_id = g.group_id
AND er.relationship_type = 'Group'
LEFT JOIN planning_center.groups_events e
ON e.event_id = er.event_id
AND e.starts_at >= CURRENT_DATE - INTERVAL '30 days'
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = aer.attendance_id
GROUP BY l.location_id, l.name, l.full_formatted_address, l.latitude, l.longitude
)
SELECT
location_name,
full_formatted_address,
groups_at_location,
total_members,
ROUND(total_members::NUMERIC / NULLIF(groups_at_location, 0), 1) as avg_members_per_group,
events_last_month,
ROUND(avg_attendance_rate, 1) as attendance_rate,
CASE
WHEN groups_at_location = 0 THEN '📍 Available Location'
WHEN groups_at_location = 1 THEN '✓ Single Group'
WHEN groups_at_location <= 3 THEN '✓ Multiple Groups'
ELSE '🔥 High Activity Hub'
END as location_status
FROM location_stats
ORDER BY groups_at_location DESC, total_members DESC;
```
## Year-End Summary Report
### Annual Groups Ministry Impact Report
```sql theme={null}
-- Comprehensive year-end summary for annual reports
WITH yearly_stats AS (
SELECT
COUNT(DISTINCT g.group_id) as total_groups,
COUNT(DISTINCT CASE WHEN g.archived_at IS NULL THEN g.group_id END) as active_groups,
COUNT(DISTINCT mr_person.relationship_id) as unique_members,
COUNT(DISTINCT CASE WHEN m.role = 'leader' THEN mr_person.relationship_id END) as unique_leaders,
COUNT(DISTINCT e.event_id) as total_events,
COUNT(DISTINCT CASE WHEN e.canceled = false THEN e.event_id END) as completed_events,
COUNT(DISTINCT a.attendance_id) as total_attendance_records,
AVG(CASE WHEN a.attended = true THEN 1.0 ELSE 0 END) * 100 as overall_attendance_rate
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m
ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.membership_id = m.membership_id AND mr_person.relationship_type = 'Person'
LEFT JOIN planning_center.groups_events_relationships er
ON er.relationship_id = g.group_id
AND er.relationship_type = 'Group'
LEFT JOIN planning_center.groups_events e
ON e.event_id = er.event_id
AND e.starts_at >= DATE_TRUNC('year', CURRENT_DATE)
AND e.starts_at < DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 year'
LEFT JOIN planning_center.groups_attendances_relationships aer
ON aer.relationship_type = 'Event' AND aer.relationship_id = e.event_id
LEFT JOIN planning_center.groups_attendances a
ON a.attendance_id = aer.attendance_id
WHERE g.created_at < DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 year'
),
growth_metrics AS (
SELECT
COUNT(DISTINCT CASE
WHEN g.created_at >= DATE_TRUNC('year', CURRENT_DATE)
THEN g.group_id
END) as new_groups_this_year,
COUNT(DISTINCT CASE
WHEN m.joined_at >= DATE_TRUNC('year', CURRENT_DATE)
THEN mr_person.relationship_id
END) as new_members_this_year
FROM planning_center.groups_groups g
LEFT JOIN planning_center.groups_memberships_relationships mr
ON mr.relationship_id = g.group_id AND mr.relationship_type = 'Group'
LEFT JOIN planning_center.groups_memberships m
ON m.membership_id = mr.membership_id
LEFT JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.membership_id = m.membership_id AND mr_person.relationship_type = 'Person'
)
SELECT
'===========================================' as divider1,
TO_CHAR(DATE_TRUNC('year', CURRENT_DATE), 'YYYY') || ' ANNUAL GROUPS MINISTRY REPORT' as report_title,
'===========================================' as divider2,
'' as blank1,
'📊 GROUP OVERVIEW' as section1,
'-------------------------------------------' as divider3,
ys.total_groups as total_groups_all_time,
ys.active_groups as currently_active_groups,
gm.new_groups_this_year as groups_launched_this_year,
ROUND(ys.active_groups::NUMERIC / NULLIF(ys.total_groups, 0) * 100, 1) as group_retention_rate,
'' as blank2,
'👥 MEMBERSHIP METRICS' as section2,
'-------------------------------------------' as divider4,
ys.unique_members as unique_members,
ys.unique_leaders as unique_leaders,
gm.new_members_this_year as new_members_this_year,
ROUND(ys.unique_members::NUMERIC / NULLIF(ys.active_groups, 0), 1) as avg_members_per_group,
ROUND(ys.unique_leaders::NUMERIC / NULLIF(ys.active_groups, 0) * 100, 1) as leader_coverage_percentage,
'' as blank3,
'📅 EVENT & ENGAGEMENT' as section3,
'-------------------------------------------' as divider5,
ys.total_events as total_events_scheduled,
ys.completed_events as events_completed,
ROUND(ys.completed_events::NUMERIC / NULLIF(ys.total_events, 0) * 100, 1) as event_completion_rate,
ROUND(ys.overall_attendance_rate, 1) as overall_attendance_rate,
ROUND(ys.total_events::NUMERIC / NULLIF(ys.active_groups, 0), 1) as avg_events_per_group,
'' as blank4,
'===========================================' as divider6
FROM yearly_stats ys, growth_metrics gm;
```
## Export Tips
These reports can be exported in various formats:
1. **CSV Export**: Add `\copy (SELECT ...) TO 'report.csv' CSV HEADER;`
2. **Excel-Ready**: Most results can be copied directly into Excel
3. **Automated Delivery**: Schedule these queries to run weekly/monthly
4. **Dashboard Integration**: Use these queries as data sources for BI tools
## Next Steps
* Review the [Data Model](/planning-center/groups/data-model) for complete field documentation
* Check [Advanced Queries](/planning-center/groups/advanced-queries) for more complex analysis techniques
* Return to [Basic Queries](/planning-center/groups/basic-queries) for simpler examples
* Visit [Overview](/planning-center/groups/overview) to understand the Groups system
# Overview
Source: https://docs.getparable.io/planning-center/index
Integrate and analyze your Planning Center data with Parable
## What is Planning Center?
Planning Center is a comprehensive suite of church management applications
designed to help churches organize information, coordinate events, communicate
with teams, connect with congregants, and manage their church operations.
## How Parable Approaches Planning Center Data
Parable acts as a data warehouse that integrates with Planning Center to provide
your church with unified, queryable access to all your ministry data:
1. **Syncs data** from all your Planning Center apps automatically
2. **Stores it** in a structured PostgreSQL database
3. **Provides SQL access** so you can query across all your data
4. **Maintains relationships** between different data types
5. **Keeps everything current** with regular synchronization
## Planning Center Apps in Parable
Parable integrates with all major Planning Center applications to provide
unified insights across your church data:
Your church directory and member management system
Donation tracking, pledges, and financial reporting
Event attendance and child safety tracking
Small groups, classes, and ministry management
Events, resources, and scheduling
Worship planning and volunteer scheduling
Event signups and payments
Media and sermon management
If you want a simple explanation of how Parable measures engagement, start
with [Engagement Scoring](/planning-center/engagement-scoring).
## Core Concepts
### Multi-Tenant Architecture
Every church (organization) in Parable has isolated data:
* Each table includes a `tenant_organization_id` column
* Data is automatically filtered by your organization
* You can only see your church's data
### Data Synchronization
Parable manages data synchronization behind the scenes:
```
Planning Center API → Parable Sync Engine → PostgreSQL Database → Your SQL Queries
```
* Data syncs automatically every night
* Full sync on initial setup
* Nightly updates thereafter
* Handles API rate limits gracefully
* Retries on failures automatically
### Database Schema Pattern
All Planning Center data follows a consistent pattern:
```text theme={null}
Main entity table
planning_center.{app}_{entity}
- {entity}_id Planning Center's ID
- tenant_organization_id Your church's ID
- system_status Data lifecycle (active/transferring/stale)
- system_created_at When Parable first synced the record
- system_updated_at When Parable last synced the record
- created_at When created in Planning Center
- updated_at When last updated in Planning Center
- [entity fields] The actual data fields
Relationship table (for connections between entities)
planning_center.{app}_{entity}_relationships
- {entity}_id Parent entity (plural table name, e.g. giving_donations_relationships.donation_id)
- relationship_type Type of related entity (e.g. 'Person', 'Fund')
- relationship_id ID of related entity
```
### System Status Explained
Every record has a `system_status` field that tracks its lifecycle:
* **`transferring`** - Being imported from Planning Center
* **`active`** - Current, valid data
* **`stale`** - Marked for removal in next sync
Parable's row-level security automatically filters to show only `active`
records, so you don't need to filter manually.
## Understanding Our Storage Approach
### Why Separate Relationship Tables?
Planning Center's API returns relationships separately from main data. We mirror
this structure for several reasons:
1. **Data Integrity** - Relationships can change independently of entities
2. **Flexibility** - One entity can have multiple relationship types
3. **Performance** - Optimized indexes for different query patterns
4. **Consistency** - Same pattern across all Planning Center apps
### Example: Connecting People to Donations
Instead of a direct foreign key, we use relationship tables:
```sql theme={null}
-- Find all donations with donor information
SELECT
d.donation_id,
d.amount_cents / 100.0 as amount,
p.first_name,
p.last_name
FROM planning_center.giving_donations d
JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_people p
ON dr.relationship_id = p.person_id
WHERE d.received_at >= DATE_TRUNC('year', CURRENT_DATE);
```
## Common Query Patterns
### Basic Entity Query
```sql theme={null}
-- Get all people
SELECT
person_id,
first_name,
last_name,
membership
FROM planning_center.people_people;
```
### Joining Through Relationships
```sql theme={null}
-- Get people with their household
SELECT
p.first_name,
p.last_name,
h.name as household_name
FROM planning_center.people_people p
JOIN planning_center.people_people_relationships pr
ON p.person_id = pr.person_id
AND pr.relationship_type = 'Household'
JOIN planning_center.people_households h
ON pr.relationship_id = h.household_id;
```
### Cross-Module Queries
```sql theme={null}
-- Find giving totals by group membership
SELECT
g.name as group_name,
COUNT(DISTINCT mr_person.relationship_id) as member_count,
SUM(d.amount_cents) / 100.0 as total_giving
FROM planning_center.groups_groups g
JOIN planning_center.groups_memberships_relationships mr_group
ON mr_group.relationship_id = g.group_id AND mr_group.relationship_type = 'Group'
JOIN planning_center.groups_memberships gm
ON gm.membership_id = mr_group.membership_id
JOIN planning_center.groups_memberships_relationships mr_person
ON mr_person.membership_id = gm.membership_id AND mr_person.relationship_type = 'Person'
JOIN planning_center.giving_donations_relationships dr
ON mr_person.relationship_id = dr.relationship_id
AND dr.relationship_type = 'Person'
JOIN planning_center.giving_donations d
ON dr.donation_id = d.donation_id
WHERE d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
GROUP BY g.name;
```
### Ministry Health Metrics
Combine attendance, giving, and group participation data to understand overall
engagement trends:
```sql theme={null}
-- Get weekly giving totals with donor counts
SELECT
DATE_TRUNC('week', d.received_at) as week_start,
COUNT(DISTINCT dr.relationship_id) as unique_donors, -- Count unique people
SUM(d.amount_cents) / 100.0 as total_amount -- Convert cents to dollars
FROM planning_center.giving_donations d
JOIN planning_center.giving_donations_relationships dr
ON d.donation_id = dr.donation_id
AND dr.relationship_type = 'Person' -- Only person relationships
WHERE d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
GROUP BY DATE_TRUNC('week', d.received_at)
ORDER BY week_start DESC;
```
## Tips for Junior Developers
Begin with basic SELECT statements on single tables:
```sql theme={null}
-- Just get some data to explore
SELECT * FROM planning_center.people_people LIMIT 10;
```
Make your queries more readable:
```sql theme={null}
-- Bad: Hard to read
SELECT planning_center.people_people.first_name
FROM planning_center.people_people;
-- Good: Clean and clear
SELECT p.first_name
FROM planning_center.people_people p;
```
Planning Center stores amounts in cents:
```sql theme={null}
-- Wrong: Shows cents
SELECT amount_cents FROM planning_center.giving_donations;
-- Right: Shows dollars
SELECT amount_cents / 100.0 as amount FROM planning_center.giving_donations;
```
Document your logic for future reference and clarity
## Troubleshooting Common Issues
Check the last sync time. Data syncs periodically (usually hourly).
Planning Center allows optional fields. NULL means the field wasn't
provided.
Check the relationship table to see available types: `sql SELECT DISTINCT
relationship_type FROM planning_center.people_people_relationships; `
You might be joining incorrectly. Make sure to: - Include the
relationship\_type in your JOIN - Use DISTINCT when counting unique entities
* Check if you need a GROUP BY clause
## Best Practices
1. **Always use meaningful aliases** for tables (p for people, d for donations)
2. **Comment complex logic** in your queries
3. **Test with LIMIT** before running large queries
4. **Use transactions** for updates (though most queries are read-only)
5. **Index awareness** - our tables are optimized for common query patterns
6. **Date filtering** - use indexed date columns for performance
## Key Benefits
Query across all your Planning Center apps with SQL, combining data that
would normally require multiple exports
Your Planning Center data stays synchronized with Parable through nightly
updates, ensuring you're working with up-to-date information
Connect to Power BI, Tableau, or other BI tools to create dashboards and
reports beyond Planning Center's built-in capabilities
Discover patterns by connecting giving data with attendance, group
participation with volunteer engagement, and more
All Planning Center data is accessed through Parable's secure, read-only
connection. Your original Planning Center data remains unchanged.
## Getting Started
To begin working with your Planning Center data in Parable:
1. **Connect Your Account**: Link your Planning Center organization to Parable
2. **Explore Your Data**: Use the SQL editor to query your synchronized data
3. **Build Dashboards**: Create custom reports in your preferred BI tool
4. **Share Insights**: Export results to share with leadership and ministry
teams
Start with simple queries to familiarize yourself with the data structure,
then gradually build more complex cross-app analyses. Remember: Every expert
was once a beginner!
## Next Steps
Ready to dive deeper? Check out our specific guides:
* [People Module Documentation](/planning-center/people/overview)
* [Giving Module Documentation](/planning-center/giving/overview)
* [Groups Module Documentation](/planning-center/groups/overview)
* [Check-ins Module Documentation](/planning-center/check-ins/overview)
* [Calendar Module Documentation](/planning-center/calendar/overview)
* [Services Module Documentation](/planning-center/services/overview)
* [Registrations Module Documentation](/planning-center/registrations/overview)
* [Publishing Module Documentation](/planning-center/publishing/overview)
## Getting Help
* **SQL Basics**: W3Schools SQL Tutorial is a great starting point
* **Planning Center API**: For detailed API information, visit [Planning Center's Developer Documentation](https://developer.planning.center/docs/#/overview/)
* **Support**: Reach out at [michael@getparable.io](mailto:michael@getparable.io)
# Advanced Planning Center People Queries
Source: https://docs.getparable.io/planning-center/people/advanced-queries
Advanced People SQL using window functions and multi-table joins: demographic analysis, geographic spread, engagement scoring, and family structure.
Master complex SQL patterns for deep insights into your congregation. These queries combine multiple tables, use window functions, and employ advanced techniques for comprehensive analysis.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center People module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.people_people`
❌ INCORRECT: `SELECT * FROM people_people`
### Row Level Security (RLS)
Row Level Security automatically filters results for:
* **tenant\_organization\_id** – only your organization's data
* **system\_status** – active records returned by default
**Do not add these filters manually**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus your filters on ministry-specific status, engagement, and demographic criteria while relying on RLS for tenancy and system status.
## Table of Contents
* [Demographic Analysis](#demographic-analysis)
* [Engagement Scoring](#engagement-scoring)
* [Family Analytics](#family-analytics)
* [Growth and Retention](#growth-and-retention)
* [Communication Optimization](#communication-optimization)
* [Volunteer Management](#volunteer-management)
* [Predictive Analytics](#predictive-analytics)
* [Performance Optimization](#performance-optimization)
## Demographic Analysis
### Comprehensive Demographic Breakdown
```sql theme={null}
-- Multi-dimensional demographic analysis
WITH demographic_data AS (
SELECT
p.person_id,
p.status,
p.membership,
-- Age calculations
EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) as age,
CASE
WHEN p.child = true THEN 'Child'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) < 18 THEN 'Youth'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) < 30 THEN 'Young Adult'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) < 50 THEN 'Adult'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) < 65 THEN 'Middle Age'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) >= 65 THEN 'Senior'
ELSE 'Unknown'
END as age_group,
-- Gender
COALESCE(p.gender, 'Not Specified') as gender,
-- Marital status
ms.value as marital_status,
-- Campus
c.name as campus,
-- Household info
CASE
WHEN h.member_count = 1 THEN 'Single'
WHEN h.member_count = 2 THEN 'Couple'
WHEN h.member_count <= 4 THEN 'Small Family'
ELSE 'Large Family'
END as household_type
FROM planning_center.people_people p
LEFT JOIN planning_center.people_people_relationships ms_r
ON ms_r.person_id = p.person_id AND ms_r.relationship_type = 'MaritalStatus'
LEFT JOIN planning_center.people_marital_statuses ms
ON ms.marital_status_id = ms_r.relationship_id
LEFT JOIN planning_center.people_people_relationships pr
ON p.person_id = pr.person_id
AND pr.relationship_type = 'PrimaryCampus'
LEFT JOIN planning_center.people_campuses c
ON pr.relationship_id = c.campus_id
LEFT JOIN planning_center.people_households_relationships hhr
ON hhr.relationship_type = 'Person' AND hhr.relationship_id = p.person_id
LEFT JOIN planning_center.people_households h
ON h.household_id = hhr.household_id
WHERE p.status = 'active'
),
demographic_summary AS (
SELECT
age_group,
gender,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 2) as percentage,
ROUND(AVG(age), 1) as avg_age_in_group,
COUNT(*) FILTER (WHERE membership = 'Member') as members,
COUNT(*) FILTER (WHERE marital_status = 'Married') as married,
COUNT(DISTINCT campus) as campus_representation
FROM demographic_data
GROUP BY age_group, gender
)
SELECT
age_group,
gender,
count,
percentage || '%' as pct_of_total,
avg_age_in_group,
ROUND(members::numeric / count * 100, 1) || '%' as membership_rate,
ROUND(married::numeric / count * 100, 1) || '%' as married_pct,
campus_representation
FROM demographic_summary
ORDER BY
CASE age_group
WHEN 'Child' THEN 1
WHEN 'Youth' THEN 2
WHEN 'Young Adult' THEN 3
WHEN 'Adult' THEN 4
WHEN 'Middle Age' THEN 5
WHEN 'Senior' THEN 6
ELSE 7
END,
gender;
```
### Geographic Distribution Analysis
```sql theme={null}
-- Analyze where your congregation lives
WITH address_analysis AS (
SELECT
a.city,
a.state,
a.zip,
COUNT(DISTINCT p.person_id) as people_count,
COUNT(DISTINCT h.household_id) as household_count,
AVG(EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate))) as avg_age,
COUNT(DISTINCT p.person_id) FILTER (WHERE p.membership = 'Member') as members,
COUNT(DISTINCT p.person_id) FILTER (WHERE p.child = true) as children
FROM planning_center.people_people p
JOIN planning_center.people_people_relationships ar
ON p.person_id = ar.person_id AND ar.relationship_type = 'Address'
JOIN planning_center.people_addresses a
ON a.address_id = ar.relationship_id
LEFT JOIN planning_center.people_households_relationships hhr
ON hhr.relationship_type = 'Person' AND hhr.relationship_id = p.person_id
LEFT JOIN planning_center.people_households h
ON h.household_id = hhr.household_id
WHERE p.status = 'active'
AND a.is_primary = true
GROUP BY a.city, a.state, a.zip
),
ranked_locations AS (
SELECT
*,
RANK() OVER (ORDER BY people_count DESC) as popularity_rank,
SUM(people_count) OVER (ORDER BY people_count DESC) as cumulative_people,
SUM(people_count) OVER () as total_people
FROM address_analysis
)
SELECT
city,
state,
zip,
people_count,
household_count,
ROUND(avg_age, 1) as avg_age,
ROUND(members::numeric / people_count * 100, 1) || '%' as membership_rate,
ROUND(children::numeric / people_count * 100, 1) || '%' as children_pct,
popularity_rank,
ROUND(cumulative_people::numeric / total_people * 100, 1) || '%' as cumulative_pct
FROM ranked_locations
WHERE popularity_rank <= 20
ORDER BY popularity_rank;
```
## Engagement Scoring
This query is a custom SQL example. If you are looking for the engagement
score your team sees in Parable, read
[Engagement Scoring](/planning-center/engagement-scoring) first.
### Multi-Factor Engagement Score
```sql theme={null}
-- Calculate comprehensive engagement score for each person
WITH engagement_metrics AS (
SELECT
p.person_id,
p.name,
p.membership,
p.created_at,
-- Tenure score (max 20 points)
LEAST(EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.created_at)) * 4, 20) as tenure_score,
-- Contact completeness (max 15 points)
(CASE WHEN e.email_id IS NOT NULL THEN 5 ELSE 0 END +
CASE WHEN pn.phone_number_id IS NOT NULL THEN 5 ELSE 0 END +
CASE WHEN a.address_id IS NOT NULL THEN 5 ELSE 0 END) as contact_score,
-- Household participation (max 15 points)
CASE
WHEN h.household_id IS NOT NULL AND h.member_count > 1 THEN 15
WHEN h.household_id IS NOT NULL THEN 10
ELSE 0
END as household_score,
-- List memberships (max 20 points)
LEAST(COUNT(DISTINCT lr.list_result_id) * 5, 20) as list_score,
-- Form submissions (max 15 points)
LEAST(COUNT(DISTINCT fs.form_submission_id) * 3, 15) as form_score,
-- Workflow participation (max 15 points)
LEAST(COUNT(DISTINCT wc.workflow_card_id) * 5, 15) as workflow_score
FROM planning_center.people_people p
-- Contact info
LEFT JOIN planning_center.people_people_relationships er
ON p.person_id = er.person_id AND er.relationship_type = 'Email'
LEFT JOIN planning_center.people_emails e
ON e.email_id = er.relationship_id AND e.is_primary = true
LEFT JOIN planning_center.people_people_relationships pnr
ON p.person_id = pnr.person_id AND pnr.relationship_type = 'PhoneNumber'
LEFT JOIN planning_center.people_phone_numbers pn
ON pn.phone_number_id = pnr.relationship_id AND pn.is_primary = true
LEFT JOIN planning_center.people_people_relationships ar
ON p.person_id = ar.person_id AND ar.relationship_type = 'Address'
LEFT JOIN planning_center.people_addresses a
ON a.address_id = ar.relationship_id AND a.is_primary = true
-- Household
LEFT JOIN planning_center.people_households_relationships hhr
ON hhr.relationship_type = 'Person' AND hhr.relationship_id = p.person_id
LEFT JOIN planning_center.people_households h
ON h.household_id = hhr.household_id
-- Lists
LEFT JOIN planning_center.people_list_results_relationships lrr
ON lrr.relationship_type = 'Person' AND lrr.relationship_id = p.person_id
LEFT JOIN planning_center.people_list_results lr
ON lr.list_result_id = lrr.list_result_id
-- Forms
LEFT JOIN planning_center.people_form_submissions_relationships fsr
ON fsr.relationship_type = 'Person' AND fsr.relationship_id = p.person_id
LEFT JOIN planning_center.people_form_submissions fs
ON fs.form_submission_id = fsr.form_submission_id
-- Workflows
LEFT JOIN planning_center.people_workflow_cards_relationships wcr
ON wcr.relationship_type = 'Person' AND wcr.relationship_id = p.person_id
LEFT JOIN planning_center.people_workflow_cards wc
ON wc.workflow_card_id = wcr.workflow_card_id
WHERE p.status = 'active'
GROUP BY
p.person_id, p.name, p.membership, p.created_at,
e.email_id, pn.phone_number_id, a.address_id,
h.household_id, h.member_count
),
scored_people AS (
SELECT
person_id,
name,
membership,
tenure_score,
contact_score,
household_score,
list_score,
form_score,
workflow_score,
tenure_score + contact_score + household_score +
list_score + form_score + workflow_score as total_score,
CASE
WHEN tenure_score + contact_score + household_score +
list_score + form_score + workflow_score >= 75 THEN 'Highly Engaged'
WHEN tenure_score + contact_score + household_score +
list_score + form_score + workflow_score >= 50 THEN 'Engaged'
WHEN tenure_score + contact_score + household_score +
list_score + form_score + workflow_score >= 25 THEN 'Moderately Engaged'
WHEN tenure_score + contact_score + household_score +
list_score + form_score + workflow_score >= 10 THEN 'Lightly Engaged'
ELSE 'New/Inactive'
END as engagement_level
FROM engagement_metrics
)
SELECT
engagement_level,
COUNT(*) as people_count,
ROUND(AVG(total_score), 1) as avg_score,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) as percentage,
COUNT(*) FILTER (WHERE membership = 'Member') as members_in_level
FROM scored_people
GROUP BY engagement_level
ORDER BY avg_score DESC;
```
### Engagement Trajectory
```sql theme={null}
-- Track engagement changes over time
WITH monthly_activity AS (
SELECT
p.person_id,
p.name,
DATE_TRUNC('month', activity_date) as month,
activity_type,
COUNT(*) as activity_count
FROM planning_center.people_people p
CROSS JOIN LATERAL (
-- Forms submitted
SELECT fs.created_at as activity_date, 'Form' as activity_type
FROM planning_center.people_form_submissions_relationships fsr
JOIN planning_center.people_form_submissions fs
ON fs.form_submission_id = fsr.form_submission_id
WHERE fsr.relationship_type = 'Person' AND fsr.relationship_id = p.person_id
UNION ALL
-- Workflow cards
SELECT wc.created_at, 'Workflow'
FROM planning_center.people_workflow_cards_relationships wcr
JOIN planning_center.people_workflow_cards wc
ON wc.workflow_card_id = wcr.workflow_card_id
WHERE wcr.relationship_type = 'Person' AND wcr.relationship_id = p.person_id
UNION ALL
-- Notes added
SELECT n.created_at, 'Note'
FROM planning_center.people_notes_relationships nr
JOIN planning_center.people_notes n
ON n.note_id = nr.note_id
WHERE nr.relationship_type = 'Person' AND nr.relationship_id = p.person_id
) activities
WHERE p.status = 'active'
AND activity_date >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY p.person_id, p.name, month, activity_type
),
engagement_trend AS (
SELECT
person_id,
name,
month,
SUM(activity_count) as total_activities,
LAG(SUM(activity_count)) OVER (PARTITION BY person_id ORDER BY month) as prev_month_activities,
AVG(SUM(activity_count)) OVER (PARTITION BY person_id) as avg_activities
FROM monthly_activity
GROUP BY person_id, name, month
)
SELECT
person_id,
name,
COUNT(DISTINCT month) as active_months,
SUM(total_activities) as total_activities_6mo,
ROUND(AVG(total_activities), 1) as avg_monthly_activities,
MAX(total_activities) as peak_month_activities,
CASE
WHEN SUM(CASE WHEN total_activities > prev_month_activities THEN 1 ELSE 0 END) >
SUM(CASE WHEN total_activities < prev_month_activities THEN 1 ELSE 0 END)
THEN 'Increasing'
WHEN SUM(CASE WHEN total_activities < prev_month_activities THEN 1 ELSE 0 END) >
SUM(CASE WHEN total_activities > prev_month_activities THEN 1 ELSE 0 END)
THEN 'Decreasing'
ELSE 'Stable'
END as engagement_trend
FROM engagement_trend
GROUP BY person_id, name
HAVING COUNT(DISTINCT month) >= 3 -- At least 3 months of data
ORDER BY total_activities_6mo DESC
LIMIT 100;
```
## Family Analytics
### Family Composition Analysis
```sql theme={null}
-- Detailed family structure analysis
WITH family_composition AS (
SELECT
h.household_id,
h.name as family_name,
h.member_count,
COUNT(DISTINCT p.person_id) as actual_members,
COUNT(DISTINCT p.person_id) FILTER (WHERE p.child = true) as children,
COUNT(DISTINCT p.person_id) FILTER (WHERE p.child = false OR p.child IS NULL) as adults,
COUNT(DISTINCT p.person_id) FILTER (WHERE p.gender = 'Male' AND (p.child = false OR p.child IS NULL)) as adult_males,
COUNT(DISTINCT p.person_id) FILTER (WHERE p.gender = 'Female' AND (p.child = false OR p.child IS NULL)) as adult_females,
MIN(p.birthdate) FILTER (WHERE p.child = false OR p.child IS NULL) as oldest_adult_birthdate,
MAX(p.birthdate) FILTER (WHERE p.child = true) as youngest_child_birthdate,
ARRAY_AGG(DISTINCT p.grade ORDER BY p.grade) FILTER (WHERE p.grade IS NOT NULL) as children_grades
FROM planning_center.people_households h
JOIN planning_center.people_households_relationships hhr
ON hhr.household_id = h.household_id AND LOWER(hhr.relationship_type) = 'person'
JOIN planning_center.people_people p
ON p.person_id = hhr.relationship_id
WHERE p.status = 'active'
GROUP BY h.household_id, h.name, h.member_count
),
family_types AS (
SELECT
*,
CASE
WHEN adults = 1 AND children = 0 THEN 'Single Adult'
WHEN adults = 2 AND children = 0 THEN 'Couple'
WHEN adults = 1 AND children > 0 THEN 'Single Parent'
WHEN adults = 2 AND children > 0 THEN 'Nuclear Family'
WHEN adults > 2 AND children > 0 THEN 'Extended Family'
WHEN adults > 2 AND children = 0 THEN 'Adult Household'
ELSE 'Other'
END as family_type,
CASE
WHEN children = 0 THEN 'No Children'
WHEN youngest_child_birthdate > CURRENT_DATE - INTERVAL '5 years' THEN 'Young Children'
WHEN youngest_child_birthdate > CURRENT_DATE - INTERVAL '12 years' THEN 'Elementary Age'
WHEN youngest_child_birthdate > CURRENT_DATE - INTERVAL '18 years' THEN 'Teenagers'
ELSE 'Adult Children'
END as children_stage
FROM family_composition
)
SELECT
family_type,
children_stage,
COUNT(*) as family_count,
ROUND(AVG(actual_members), 1) as avg_size,
ROUND(AVG(children), 1) as avg_children,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) as percentage
FROM family_types
GROUP BY family_type, children_stage
ORDER BY family_count DESC;
```
### Multi-Generational Households
```sql theme={null}
-- Identify multi-generational families
WITH household_ages AS (
SELECT
h.household_id,
h.name as household_name,
p.person_id,
p.name as person_name,
p.birthdate,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) as age,
CASE
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) < 18 THEN 'Child'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) < 40 THEN 'Young Adult'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) < 65 THEN 'Middle Age'
ELSE 'Senior'
END as generation
FROM planning_center.people_households h
JOIN planning_center.people_households_relationships hhr
ON hhr.household_id = h.household_id AND LOWER(hhr.relationship_type) = 'person'
JOIN planning_center.people_people p
ON p.person_id = hhr.relationship_id
WHERE p.status = 'active'
AND p.birthdate IS NOT NULL
),
generation_analysis AS (
SELECT
household_id,
household_name,
COUNT(DISTINCT generation) as generation_count,
COUNT(DISTINCT person_id) as member_count,
MAX(age) - MIN(age) as age_span,
STRING_AGG(DISTINCT generation, ', ' ORDER BY generation) as generations_present,
MIN(age) as youngest_age,
MAX(age) as oldest_age
FROM household_ages
GROUP BY household_id, household_name
)
SELECT
household_id,
household_name,
member_count,
generation_count,
generations_present,
age_span as age_span_years,
youngest_age,
oldest_age,
CASE
WHEN generation_count >= 3 THEN 'Multi-Generational'
WHEN generation_count = 2 AND age_span > 30 THEN 'Likely Multi-Gen'
WHEN generation_count = 2 THEN 'Two Generations'
ELSE 'Single Generation'
END as household_type
FROM generation_analysis
WHERE generation_count >= 2
ORDER BY generation_count DESC, age_span DESC;
```
## Growth and Retention
### Cohort Retention Analysis
```sql theme={null}
-- Track retention by cohort over time
WITH cohort_base AS (
SELECT
DATE_TRUNC('month', created_at) as cohort_month,
person_id,
created_at,
status,
inactivated_at
FROM planning_center.people_people
WHERE created_at >= CURRENT_DATE - INTERVAL '24 months'
),
retention_calc AS (
SELECT
cohort_month,
COUNT(DISTINCT person_id) as cohort_size,
COUNT(DISTINCT person_id) FILTER (
WHERE status = 'active'
OR inactivated_at > cohort_month + INTERVAL '1 month'
) as month_1,
COUNT(DISTINCT person_id) FILTER (
WHERE status = 'active'
OR inactivated_at > cohort_month + INTERVAL '3 months'
) as month_3,
COUNT(DISTINCT person_id) FILTER (
WHERE status = 'active'
OR inactivated_at > cohort_month + INTERVAL '6 months'
) as month_6,
COUNT(DISTINCT person_id) FILTER (
WHERE status = 'active'
OR inactivated_at > cohort_month + INTERVAL '12 months'
) as month_12,
COUNT(DISTINCT person_id) FILTER (
WHERE status = 'active'
) as still_active
FROM cohort_base
GROUP BY cohort_month
)
SELECT
TO_CHAR(cohort_month, 'YYYY-MM') as cohort,
cohort_size as started,
ROUND(month_1::numeric / cohort_size * 100, 1) as pct_retained_1mo,
ROUND(month_3::numeric / cohort_size * 100, 1) as pct_retained_3mo,
ROUND(month_6::numeric / cohort_size * 100, 1) as pct_retained_6mo,
ROUND(month_12::numeric / cohort_size * 100, 1) as pct_retained_12mo,
ROUND(still_active::numeric / cohort_size * 100, 1) as pct_still_active,
still_active as currently_active
FROM retention_calc
WHERE cohort_month <= CURRENT_DATE - INTERVAL '1 month'
ORDER BY cohort_month DESC;
```
### Growth Velocity Analysis
```sql theme={null}
-- Analyze growth patterns and velocity
WITH weekly_metrics AS (
SELECT
DATE_TRUNC('week', d.date) as week,
-- New people
COUNT(DISTINCT p.person_id) FILTER (
WHERE DATE_TRUNC('week', p.created_at) = DATE_TRUNC('week', d.date)
) as new_people,
-- Inactivated people
COUNT(DISTINCT p.person_id) FILTER (
WHERE DATE_TRUNC('week', p.inactivated_at) = DATE_TRUNC('week', d.date)
) as inactivated_people,
-- Total active at end of week
COUNT(DISTINCT p.person_id) FILTER (
WHERE p.created_at <= d.date + INTERVAL '6 days'
AND (p.inactivated_at IS NULL OR p.inactivated_at > d.date + INTERVAL '6 days')
) as total_active
FROM generate_series(
CURRENT_DATE - INTERVAL '12 weeks',
CURRENT_DATE,
INTERVAL '1 week'
) d(date)
CROSS JOIN planning_center.people_people p
GROUP BY DATE_TRUNC('week', d.date)
),
growth_analysis AS (
SELECT
week,
new_people,
inactivated_people,
new_people - inactivated_people as net_growth,
total_active,
LAG(total_active) OVER (ORDER BY week) as prev_week_active,
AVG(new_people) OVER (ORDER BY week ROWS BETWEEN 3 PRECEDING AND CURRENT ROW) as avg_new_4wk,
AVG(new_people - inactivated_people) OVER (ORDER BY week ROWS BETWEEN 3 PRECEDING AND CURRENT ROW) as avg_net_4wk
FROM weekly_metrics
)
SELECT
TO_CHAR(week, 'YYYY-MM-DD') as week_starting,
new_people,
inactivated_people,
net_growth,
total_active,
ROUND((total_active - prev_week_active)::numeric / NULLIF(prev_week_active, 0) * 100, 2) as weekly_growth_rate,
ROUND(avg_new_4wk, 1) as rolling_avg_new,
ROUND(avg_net_4wk, 1) as rolling_avg_net,
CASE
WHEN net_growth > avg_net_4wk * 1.5 THEN 'Accelerating'
WHEN net_growth > avg_net_4wk THEN 'Above Average'
WHEN net_growth > 0 THEN 'Growing'
WHEN net_growth = 0 THEN 'Flat'
ELSE 'Declining'
END as growth_status
FROM growth_analysis
WHERE week <= CURRENT_DATE
ORDER BY week DESC;
```
## Communication Optimization
### Communication Preference Analysis
```sql theme={null}
-- Analyze best communication channels by demographic
WITH communication_channels AS (
SELECT
p.person_id,
p.name,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) as age,
p.gender,
p.membership,
-- Email status
CASE
WHEN e.email_id IS NOT NULL AND e.blocked = false THEN 'Has Email'
WHEN e.email_id IS NOT NULL AND e.blocked = true THEN 'Email Blocked'
ELSE 'No Email'
END as email_status,
-- Phone status
CASE
WHEN pn.phone_number_id IS NOT NULL AND pn.carrier IS NOT NULL THEN 'SMS Capable'
WHEN pn.phone_number_id IS NOT NULL THEN 'Voice Only'
ELSE 'No Phone'
END as phone_status,
-- Physical mail
CASE
WHEN a.address_id IS NOT NULL THEN 'Has Address'
ELSE 'No Address'
END as mail_status
FROM planning_center.people_people p
LEFT JOIN planning_center.people_people_relationships er
ON p.person_id = er.person_id AND er.relationship_type = 'Email'
LEFT JOIN planning_center.people_emails e
ON e.email_id = er.relationship_id AND e.is_primary = true
LEFT JOIN planning_center.people_people_relationships pnr
ON p.person_id = pnr.person_id AND pnr.relationship_type = 'PhoneNumber'
LEFT JOIN planning_center.people_phone_numbers pn
ON pn.phone_number_id = pnr.relationship_id AND pn.is_primary = true
LEFT JOIN planning_center.people_people_relationships ar
ON p.person_id = ar.person_id AND ar.relationship_type = 'Address'
LEFT JOIN planning_center.people_addresses a
ON a.address_id = ar.relationship_id AND a.is_primary = true
WHERE p.status = 'active'
),
channel_summary AS (
SELECT
CASE
WHEN age < 30 THEN 'Under 30'
WHEN age < 50 THEN '30-49'
WHEN age < 70 THEN '50-69'
WHEN age >= 70 THEN '70+'
ELSE 'Unknown'
END as age_group,
COUNT(*) as total_people,
COUNT(*) FILTER (WHERE email_status = 'Has Email') as has_email,
COUNT(*) FILTER (WHERE phone_status = 'SMS Capable') as has_sms,
COUNT(*) FILTER (WHERE phone_status IN ('SMS Capable', 'Voice Only')) as has_phone,
COUNT(*) FILTER (WHERE mail_status = 'Has Address') as has_address,
-- Best channel determination
COUNT(*) FILTER (WHERE email_status = 'Has Email' AND phone_status = 'SMS Capable') as multi_channel,
COUNT(*) FILTER (WHERE email_status != 'Has Email' AND phone_status != 'SMS Capable' AND mail_status = 'Has Address') as mail_only
FROM communication_channels
GROUP BY age_group
)
SELECT
age_group,
total_people,
ROUND(has_email::numeric / total_people * 100, 1) || '%' as email_reach,
ROUND(has_sms::numeric / total_people * 100, 1) || '%' as sms_reach,
ROUND(has_phone::numeric / total_people * 100, 1) || '%' as phone_reach,
ROUND(has_address::numeric / total_people * 100, 1) || '%' as mail_reach,
ROUND(multi_channel::numeric / total_people * 100, 1) || '%' as multi_channel_pct,
mail_only as mail_only_count,
CASE
WHEN has_sms::numeric / total_people > 0.7 THEN 'SMS Preferred'
WHEN has_email::numeric / total_people > 0.8 THEN 'Email Preferred'
WHEN has_phone::numeric / total_people > 0.9 THEN 'Phone Preferred'
ELSE 'Mixed Channels'
END as recommended_primary
FROM channel_summary
ORDER BY
CASE age_group
WHEN 'Under 30' THEN 1
WHEN '30-49' THEN 2
WHEN '50-69' THEN 3
WHEN '70+' THEN 4
ELSE 5
END;
```
## Volunteer Management
### Volunteer Capacity Analysis
```sql theme={null}
-- Identify volunteer capacity and opportunities
-- Each person can have many list results, workflow cards and field values.
-- Joining all of them in one pass multiplies the rows together, which is slow
-- enough to time out on a large database. Summarize each dimension separately,
-- then join the summaries.
WITH background AS (
SELECT
bcr.relationship_id as person_id,
-- Planning Center reports a family of statuses, not a simple
-- pass/fail: *_clear is a pass, *_not_clear is a fail, and the rest
-- are still in flight. There is no 'passed' value.
MAX(CASE
WHEN bc.status IN ('manual_clear', 'report_clear', 'complete_clear') THEN 1
ELSE 0
END) as has_clear,
MAX(CASE
WHEN bc.status IN ('manual_not_clear', 'complete_not_clear') THEN 1
ELSE 0
END) as has_not_clear,
MAX(CASE
WHEN bc.status IN ('report_consider', 'report_suspended') THEN 1
ELSE 0
END) as needs_review,
COUNT(*) as check_count
FROM planning_center.people_background_checks_relationships bcr
JOIN planning_center.people_background_checks bc
ON bc.background_check_id = bcr.background_check_id
WHERE bcr.relationship_type = 'Person'
GROUP BY bcr.relationship_id
),
list_counts AS (
SELECT
lrr.relationship_id as person_id,
COUNT(DISTINCT lrr.list_result_id) as list_memberships
FROM planning_center.people_list_results_relationships lrr
WHERE lrr.relationship_type = 'Person'
GROUP BY lrr.relationship_id
),
workflow_counts AS (
SELECT
wcr.relationship_id as person_id,
COUNT(DISTINCT wc.workflow_card_id) FILTER (WHERE wc.completed_at IS NOT NULL) as completed_workflows
FROM planning_center.people_workflow_cards_relationships wcr
JOIN planning_center.people_workflow_cards wc
ON wc.workflow_card_id = wcr.workflow_card_id
WHERE wcr.relationship_type = 'Person'
GROUP BY wcr.relationship_id
),
custom_fields AS (
SELECT
fdr.relationship_id as person_id,
MAX(CASE WHEN fd.name = 'Skills' THEN fdat.value END) as skills,
MAX(CASE WHEN fd.name = 'Availability' THEN fdat.value END) as availability
FROM planning_center.people_field_data_relationships fdr
JOIN planning_center.people_field_data fdat
ON fdat.field_data_id = fdr.field_data_id
JOIN planning_center.people_field_data_relationships fdr_fd
ON fdr_fd.field_data_id = fdat.field_data_id
AND fdr_fd.relationship_type = 'FieldDefinition'
JOIN planning_center.people_field_definitions fd
ON fd.field_definition_id = fdr_fd.relationship_id
AND fd.name IN ('Skills', 'Availability')
WHERE fdr.relationship_type = 'Person'
GROUP BY fdr.relationship_id
),
volunteer_data AS (
SELECT
p.person_id,
p.name,
p.membership,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) as age,
CASE
WHEN b.has_clear = 1 THEN 'Cleared'
WHEN b.has_not_clear = 1 THEN 'Not Cleared'
WHEN b.needs_review = 1 THEN 'Needs Review'
WHEN b.check_count > 0 THEN 'In Progress'
ELSE 'Not Checked'
END as background_status,
COALESCE(lc.list_memberships, 0) as list_memberships,
COALESCE(wcnt.completed_workflows, 0) as completed_workflows,
cf.skills,
cf.availability
FROM planning_center.people_people p
LEFT JOIN background b ON b.person_id = p.person_id
LEFT JOIN list_counts lc ON lc.person_id = p.person_id
LEFT JOIN workflow_counts wcnt ON wcnt.person_id = p.person_id
LEFT JOIN custom_fields cf ON cf.person_id = p.person_id
WHERE p.status = 'active'
AND p.child = false -- Adults only
AND (p.membership = 'Member' OR COALESCE(lc.list_memberships, 0) > 0)
),
volunteer_segments AS (
SELECT
person_id,
name,
age,
background_status,
list_memberships,
completed_workflows,
CASE
WHEN list_memberships >= 3 THEN 'Highly Active'
WHEN list_memberships >= 1 THEN 'Active'
WHEN membership = 'Member' THEN 'Available'
ELSE 'Potential'
END as volunteer_status,
CASE
WHEN background_status = 'Cleared' AND age >= 18 THEN 'Children/Youth Ready'
WHEN age >= 18 AND age < 65 THEN 'General Service Ready'
WHEN age >= 65 THEN 'Senior Service Ready'
ELSE 'Not Ready'
END as service_readiness
FROM volunteer_data
)
SELECT
volunteer_status,
service_readiness,
COUNT(*) as people_count,
ROUND(AVG(age), 1) as avg_age,
COUNT(*) FILTER (WHERE background_status = 'Cleared') as background_cleared,
ROUND(AVG(list_memberships), 1) as avg_involvements,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) as percentage
FROM volunteer_segments
GROUP BY volunteer_status, service_readiness
ORDER BY
CASE volunteer_status
WHEN 'Highly Active' THEN 1
WHEN 'Active' THEN 2
WHEN 'Available' THEN 3
ELSE 4
END,
service_readiness;
```
## Predictive Analytics
### Churn Risk Prediction
```sql theme={null}
-- Identify people at risk of becoming inactive
-- Forms, workflow cards, notes and list results are all one-to-many against a
-- person. Joining them together multiplies the rows, so each is summarized on
-- its own before being joined back.
WITH form_activity AS (
SELECT
fsr.relationship_id as person_id,
MAX(fs.created_at) as last_form_submission,
COUNT(DISTINCT DATE_TRUNC('month', fs.created_at)) as active_months_forms
FROM planning_center.people_form_submissions_relationships fsr
JOIN planning_center.people_form_submissions fs
ON fs.form_submission_id = fsr.form_submission_id
WHERE fsr.relationship_type = 'Person'
GROUP BY fsr.relationship_id
),
workflow_activity AS (
SELECT
wcr.relationship_id as person_id,
MAX(wc.created_at) as last_workflow_activity,
COUNT(DISTINCT DATE_TRUNC('month', wc.created_at)) as active_months_workflows
FROM planning_center.people_workflow_cards_relationships wcr
JOIN planning_center.people_workflow_cards wc
ON wc.workflow_card_id = wcr.workflow_card_id
WHERE wcr.relationship_type = 'Person'
GROUP BY wcr.relationship_id
),
note_activity AS (
SELECT
nr.relationship_id as person_id,
MAX(n.created_at) as last_note
FROM planning_center.people_notes_relationships nr
JOIN planning_center.people_notes n
ON n.note_id = nr.note_id
WHERE nr.relationship_type = 'Person'
GROUP BY nr.relationship_id
),
list_activity AS (
SELECT
lrr.relationship_id as person_id,
COUNT(DISTINCT lrr.list_result_id) as list_count
FROM planning_center.people_list_results_relationships lrr
WHERE lrr.relationship_type = 'Person'
GROUP BY lrr.relationship_id
),
household_membership AS (
SELECT DISTINCT relationship_id as person_id
FROM planning_center.people_households_relationships
WHERE relationship_type = 'Person'
),
activity_metrics AS (
SELECT
p.person_id,
p.name,
p.created_at,
p.membership,
fa.last_form_submission,
wa.last_workflow_activity,
na.last_note,
COALESCE(fa.active_months_forms, 0) as active_months_forms,
COALESCE(wa.active_months_workflows, 0) as active_months_workflows,
COALESCE(la.list_count, 0) as list_count,
CASE WHEN hm.person_id IS NOT NULL THEN 1 ELSE 0 END as has_household
FROM planning_center.people_people p
LEFT JOIN form_activity fa ON fa.person_id = p.person_id
LEFT JOIN workflow_activity wa ON wa.person_id = p.person_id
LEFT JOIN note_activity na ON na.person_id = p.person_id
LEFT JOIN list_activity la ON la.person_id = p.person_id
LEFT JOIN household_membership hm ON hm.person_id = p.person_id
WHERE p.status = 'active'
AND p.created_at < CURRENT_DATE - INTERVAL '90 days' -- Established people only
),
risk_scoring AS (
SELECT
person_id,
name,
membership,
-- Risk factors
CASE WHEN COALESCE(last_form_submission, last_workflow_activity, last_note) < CURRENT_DATE - INTERVAL '90 days'
OR COALESCE(last_form_submission, last_workflow_activity, last_note) IS NULL
THEN 3 ELSE 0 END as inactivity_risk,
CASE WHEN list_count = 0 THEN 2 ELSE 0 END as disconnection_risk,
CASE WHEN has_household = 0 THEN 1 ELSE 0 END as isolation_risk,
CASE WHEN active_months_forms + active_months_workflows < 3 THEN 2 ELSE 0 END as low_engagement_risk,
-- Last activity
GREATEST(
COALESCE(last_form_submission, '1900-01-01'::timestamp),
COALESCE(last_workflow_activity, '1900-01-01'::timestamp),
COALESCE(last_note, '1900-01-01'::timestamp)
) as last_activity,
-- Engagement score
active_months_forms + active_months_workflows as total_active_months,
list_count
FROM activity_metrics
)
SELECT
person_id,
name,
membership,
TO_CHAR(last_activity, 'YYYY-MM-DD') as last_seen,
CURRENT_DATE - last_activity::date as days_inactive,
inactivity_risk + disconnection_risk + isolation_risk + low_engagement_risk as total_risk_score,
CASE
WHEN inactivity_risk + disconnection_risk + isolation_risk + low_engagement_risk >= 5 THEN 'High Risk'
WHEN inactivity_risk + disconnection_risk + isolation_risk + low_engagement_risk >= 3 THEN 'Medium Risk'
WHEN inactivity_risk + disconnection_risk + isolation_risk + low_engagement_risk >= 1 THEN 'Low Risk'
ELSE 'Stable'
END as risk_level,
ARRAY_REMOVE(ARRAY[
CASE WHEN inactivity_risk > 0 THEN 'Long Inactivity' END,
CASE WHEN disconnection_risk > 0 THEN 'No Groups/Lists' END,
CASE WHEN isolation_risk > 0 THEN 'No Household' END,
CASE WHEN low_engagement_risk > 0 THEN 'Low Engagement' END
], NULL) as risk_factors
FROM risk_scoring
WHERE inactivity_risk + disconnection_risk + isolation_risk + low_engagement_risk > 0
ORDER BY total_risk_score DESC, days_inactive DESC
LIMIT 100;
```
## Performance Optimization
### Dashboard Metrics Rollup
Your Parable database connection is **read-only**. You cannot create
materialized views or indexes through it. Run this query directly, schedule it
as a Parable report, or let your BI tool cache the result set.
```sql theme={null}
-- Frequently accessed people metrics — schedule as a report or BI dataset
WITH base_metrics AS (
SELECT
p.person_id,
p.status,
p.membership,
p.child,
p.gender,
p.created_at,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) as age,
-- Campus
c.name as campus,
-- Household
h.member_count as household_size,
-- Contact completeness
CASE WHEN e.email_id IS NOT NULL THEN 1 ELSE 0 END as has_email,
CASE WHEN pn.phone_number_id IS NOT NULL THEN 1 ELSE 0 END as has_phone,
CASE WHEN a.address_id IS NOT NULL THEN 1 ELSE 0 END as has_address
FROM planning_center.people_people p
LEFT JOIN planning_center.people_people_relationships pr
ON p.person_id = pr.person_id AND pr.relationship_type = 'PrimaryCampus'
LEFT JOIN planning_center.people_campuses c
ON pr.relationship_id = c.campus_id
LEFT JOIN planning_center.people_households_relationships hhr
ON hhr.relationship_type = 'Person' AND hhr.relationship_id = p.person_id
LEFT JOIN planning_center.people_households h
ON h.household_id = hhr.household_id
LEFT JOIN planning_center.people_people_relationships er
ON p.person_id = er.person_id AND er.relationship_type = 'Email'
LEFT JOIN planning_center.people_emails e
ON e.email_id = er.relationship_id AND e.is_primary = true
LEFT JOIN planning_center.people_people_relationships pnr
ON p.person_id = pnr.person_id AND pnr.relationship_type = 'PhoneNumber'
LEFT JOIN planning_center.people_phone_numbers pn
ON pn.phone_number_id = pnr.relationship_id AND pn.is_primary = true
LEFT JOIN planning_center.people_people_relationships ar
ON p.person_id = ar.person_id AND ar.relationship_type = 'Address'
LEFT JOIN planning_center.people_addresses a
ON a.address_id = ar.relationship_id AND a.is_primary = true
)
SELECT
COUNT(*) FILTER (WHERE status = 'active') as total_active,
COUNT(*) FILTER (WHERE status = 'inactive') as total_inactive,
COUNT(*) FILTER (WHERE membership = 'Member') as total_members,
COUNT(*) FILTER (WHERE child = true) as total_children,
COUNT(*) FILTER (WHERE gender = 'Male') as total_males,
COUNT(*) FILTER (WHERE gender = 'Female') as total_females,
AVG(age) FILTER (WHERE age IS NOT NULL) as avg_age,
COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days') as new_last_30_days,
COUNT(*) FILTER (WHERE has_email = 1) as with_email,
COUNT(*) FILTER (WHERE has_phone = 1) as with_phone,
COUNT(*) FILTER (WHERE has_address = 1) as with_address,
COUNT(DISTINCT campus) as campus_count,
AVG(household_size) as avg_household_size,
CURRENT_TIMESTAMP as last_refreshed
FROM base_metrics;
```
## Best Practices
1. **Use CTEs for Clarity**: Break complex queries into logical steps
2. **Leverage Window Functions**: Use OVER() for running totals and comparisons
3. **Filter Early**: Apply WHERE clauses as early as possible
4. **Index Strategic Columns**: Ensure frequently joined/filtered columns are indexed
5. **Monitor Query Performance**: Use EXPLAIN ANALYZE for optimization
## Next Steps
Apply these advanced queries to real ministry scenarios:
* [Reporting Examples](/planning-center/people/reporting-examples) - Practical ministry applications and reports
# Basic Planning Center People Queries
Source: https://docs.getparable.io/planning-center/people/basic-queries
Start querying Planning Center People data: active people, lookup by name, recent additions, age grouping, and finding children and students.
Start with these foundational queries to explore your church database. Each example builds your SQL confidence while providing immediate ministry value.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center People module live in the `planning_center` schema. Always prefix table names with `planning_center.` in every query.
✅ CORRECT: `SELECT * FROM planning_center.people_people`
❌ INCORRECT: `SELECT * FROM people_people`
### Row Level Security (RLS)
Row Level Security automatically filters results for:
* **tenant\_organization\_id** – only data from your organization
* **system\_status** – only active records by default
**Do not add these filters manually**—RLS applies them for you and redundant conditions can slow queries or hide data:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Trust the built-in RLS policies to keep your results scoped correctly while you focus on ministry-specific filters.
## Table of Contents
* [Finding People](#finding-people)
* [Understanding Demographics](#understanding-demographics)
* [Working with Households](#working-with-households)
* [Contact Information](#contact-information)
* [Membership and Status](#membership-and-status)
* [Date-Based Queries](#date-based-queries)
* [Basic Statistics](#basic-statistics)
## Finding People
### List Active People
```sql theme={null}
-- View active people in your database
SELECT
person_id,
first_name,
last_name,
name,
status,
membership,
created_at
FROM planning_center.people_people
WHERE status = 'active'
ORDER BY last_name, first_name
LIMIT 100;
```
### Search by Name
```sql theme={null}
-- Find people by name (case-insensitive)
SELECT
person_id,
name,
nickname,
status,
membership,
birthdate
FROM planning_center.people_people
WHERE status = 'active'
AND (
LOWER(first_name) LIKE '%sarah%'
OR LOWER(last_name) LIKE '%johnson%'
OR LOWER(nickname) LIKE '%sarah%'
)
ORDER BY last_name, first_name;
```
### Recent Additions
```sql theme={null}
-- People added in the last 30 days
SELECT
person_id,
name,
status,
membership,
created_at,
CURRENT_DATE - created_at::date as days_since_added
FROM planning_center.people_people
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
AND status = 'active'
ORDER BY created_at DESC;
```
### Find by Person ID
```sql theme={null}
-- Look up a specific person
SELECT
person_id,
name,
first_name,
last_name,
nickname,
birthdate,
anniversary,
gender,
membership,
status,
child,
grade,
graduation_year
FROM planning_center.people_people
WHERE person_id = 'YOUR_PERSON_ID'
AND status = 'active';
```
## Understanding Demographics
### Age Distribution
```sql theme={null}
-- Calculate ages and group people
SELECT
person_id,
name,
birthdate,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) as age,
CASE
WHEN child = true THEN 'Child'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 18 THEN 'Youth'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 30 THEN 'Young Adult'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 50 THEN 'Adult'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 65 THEN 'Middle Age'
ELSE 'Senior'
END as age_group
FROM planning_center.people_people
WHERE birthdate IS NOT NULL
AND status = 'active'
ORDER BY birthdate;
```
### Children and Students
```sql theme={null}
-- Find all children and students
SELECT
person_id,
name,
birthdate,
grade,
graduation_year,
school_type,
CASE
WHEN grade IS NOT NULL THEN 'Grade ' || grade::text
WHEN graduation_year IS NOT NULL THEN 'Grad Year ' || graduation_year::text
WHEN child = true THEN 'Child'
ELSE 'Unknown'
END as education_status
FROM planning_center.people_people
WHERE (child = true OR grade IS NOT NULL OR graduation_year IS NOT NULL)
AND status = 'active'
ORDER BY grade, graduation_year, name;
```
### Gender Breakdown
```sql theme={null}
-- Analyze gender distribution
SELECT
gender,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) as percentage
FROM planning_center.people_people
WHERE status = 'active'
GROUP BY gender
ORDER BY count DESC;
```
### Upcoming Birthdays
```sql theme={null}
-- Birthdays in the next 30 days
SELECT
person_id,
name,
birthdate,
TO_CHAR(birthdate, 'FMMonth DD') as birthday,
EXTRACT(YEAR FROM AGE(
DATE(EXTRACT(YEAR FROM CURRENT_DATE) || '-' ||
EXTRACT(MONTH FROM birthdate) || '-' ||
EXTRACT(DAY FROM birthdate)),
birthdate
)) as turning_age,
-- Calculate days until birthday
CASE
WHEN DATE_PART('doy', birthdate) >= DATE_PART('doy', CURRENT_DATE)
THEN DATE_PART('doy', birthdate) - DATE_PART('doy', CURRENT_DATE)
ELSE 365 - DATE_PART('doy', CURRENT_DATE) + DATE_PART('doy', birthdate)
END as days_until
FROM planning_center.people_people
WHERE birthdate IS NOT NULL
AND status = 'active'
AND (
-- Birthday in current year
(EXTRACT(MONTH FROM birthdate) = EXTRACT(MONTH FROM CURRENT_DATE)
AND EXTRACT(DAY FROM birthdate) >= EXTRACT(DAY FROM CURRENT_DATE))
OR
(EXTRACT(MONTH FROM birthdate) = EXTRACT(MONTH FROM CURRENT_DATE + INTERVAL '1 month'))
)
ORDER BY
EXTRACT(MONTH FROM birthdate),
EXTRACT(DAY FROM birthdate);
```
## Working with Households
### List All Households
```sql theme={null}
-- View all households with member counts
SELECT
household_id,
name,
member_count,
primary_contact_name,
created_at
FROM planning_center.people_households
ORDER BY name
LIMIT 100;
```
### Find Household Members
```sql theme={null}
-- Get all members of households
SELECT
h.household_id,
h.name as household_name,
h.member_count,
p.name as member_name,
p.birthdate,
CASE
WHEN p.child = true THEN 'Child'
ELSE 'Adult'
END as member_type
FROM planning_center.people_households h
JOIN planning_center.people_households_relationships hr
ON hr.household_id = h.household_id
AND LOWER(hr.relationship_type) = 'person'
JOIN planning_center.people_people p
ON p.person_id = hr.relationship_id
WHERE p.status = 'active'
ORDER BY h.name, p.birthdate;
```
### Large Families
```sql theme={null}
-- Find households with 5 or more members
SELECT
household_id,
name,
member_count,
primary_contact_name
FROM planning_center.people_households
WHERE member_count >= 5
ORDER BY member_count DESC;
```
### Single-Person Households
```sql theme={null}
-- Identify people living alone
SELECT
h.household_id,
h.name as household_name,
p.name as person_name,
p.birthdate,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate)) as age
FROM planning_center.people_households h
JOIN planning_center.people_households_relationships hhr
ON hhr.household_id = h.household_id AND LOWER(hhr.relationship_type) = 'person'
JOIN planning_center.people_people p
ON p.person_id = hhr.relationship_id
WHERE h.member_count = 1
AND p.status = 'active'
ORDER BY p.birthdate;
```
## Contact Information
### People with Email Addresses
```sql theme={null}
-- Find primary email addresses
SELECT
p.person_id,
p.name,
e.address as email,
e.location as email_type,
e.is_primary,
e.blocked
FROM planning_center.people_people p
JOIN planning_center.people_people_relationships er
ON p.person_id = er.person_id AND er.relationship_type = 'Email'
JOIN planning_center.people_emails e
ON e.email_id = er.relationship_id
WHERE p.status = 'active'
AND e.is_primary = true
AND e.blocked = false
ORDER BY p.last_name, p.first_name
LIMIT 100;
```
### People with Phone Numbers
```sql theme={null}
-- Find phone numbers by type
SELECT
p.person_id,
p.name,
pn.number,
pn.location as phone_type,
pn.is_primary
FROM planning_center.people_people p
JOIN planning_center.people_people_relationships pnr
ON p.person_id = pnr.person_id AND pnr.relationship_type = 'PhoneNumber'
JOIN planning_center.people_phone_numbers pn
ON pn.phone_number_id = pnr.relationship_id
WHERE p.status = 'active'
AND pn.is_primary = true
ORDER BY p.last_name, p.first_name
LIMIT 100;
```
### Complete Contact Info
```sql theme={null}
-- Get all contact methods for active people
SELECT
p.person_id,
p.name,
e.address as email,
pn.number as phone,
a.street_line_1,
a.city,
a.state,
a.zip
FROM planning_center.people_people p
LEFT JOIN planning_center.people_people_relationships er
ON p.person_id = er.person_id AND er.relationship_type = 'Email'
LEFT JOIN planning_center.people_emails e
ON e.email_id = er.relationship_id AND e.is_primary = true
LEFT JOIN planning_center.people_people_relationships pnr
ON p.person_id = pnr.person_id AND pnr.relationship_type = 'PhoneNumber'
LEFT JOIN planning_center.people_phone_numbers pn
ON pn.phone_number_id = pnr.relationship_id AND pn.is_primary = true
LEFT JOIN planning_center.people_people_relationships ar
ON p.person_id = ar.person_id AND ar.relationship_type = 'Address'
LEFT JOIN planning_center.people_addresses a
ON a.address_id = ar.relationship_id AND a.is_primary = true
WHERE p.status = 'active'
ORDER BY p.last_name, p.first_name
LIMIT 50;
```
### People Missing Contact Info
```sql theme={null}
-- Find people without email or phone
SELECT
p.person_id,
p.name,
p.created_at,
CASE
WHEN e.email_id IS NULL AND pn.phone_number_id IS NULL THEN 'No Contact Info'
WHEN e.email_id IS NULL THEN 'No Email'
WHEN pn.phone_number_id IS NULL THEN 'No Phone'
ELSE 'Has Contact Info'
END as contact_status
FROM planning_center.people_people p
LEFT JOIN planning_center.people_people_relationships er
ON p.person_id = er.person_id AND er.relationship_type = 'Email'
LEFT JOIN planning_center.people_emails e
ON e.email_id = er.relationship_id
LEFT JOIN planning_center.people_people_relationships pnr
ON p.person_id = pnr.person_id AND pnr.relationship_type = 'PhoneNumber'
LEFT JOIN planning_center.people_phone_numbers pn
ON pn.phone_number_id = pnr.relationship_id
WHERE p.status = 'active'
AND p.child = false -- Adults only
AND (e.email_id IS NULL OR pn.phone_number_id IS NULL)
ORDER BY p.created_at DESC;
```
## Membership and Status
### Membership Levels
```sql theme={null}
-- Count people by membership level
SELECT
membership,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) as percentage
FROM planning_center.people_people
WHERE status = 'active'
GROUP BY membership
ORDER BY count DESC;
```
### New Members
```sql theme={null}
-- People who became members recently
SELECT
person_id,
name,
membership,
created_at,
updated_at
FROM planning_center.people_people
WHERE status = 'active'
AND membership = 'Member'
AND updated_at >= CURRENT_DATE - INTERVAL '90 days'
ORDER BY updated_at DESC;
```
### Permission Levels
```sql theme={null}
-- Find people with administrative permissions
SELECT
person_id,
name,
site_administrator,
people_permissions,
can_create_forms,
can_email_lists
FROM planning_center.people_people
WHERE status = 'active'
AND (
site_administrator = true
OR people_permissions IS NOT NULL
OR can_create_forms = true
OR can_email_lists = true
)
ORDER BY name;
```
### Inactive People
```sql theme={null}
-- Recently inactivated people (for follow-up)
SELECT
p.person_id,
p.name,
p.inactivated_at,
p.membership,
ir.value as inactive_reason
FROM planning_center.people_people p
LEFT JOIN planning_center.people_people_relationships pr
ON p.person_id = pr.person_id AND pr.relationship_type = 'InactiveReason'
LEFT JOIN planning_center.people_inactive_reasons ir
ON ir.inactive_reason_id = pr.relationship_id
WHERE p.status = 'inactive'
AND p.inactivated_at >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY p.inactivated_at DESC;
```
## Date-Based Queries
### Anniversary This Month
```sql theme={null}
-- Wedding anniversaries this month
SELECT
person_id,
name,
anniversary,
TO_CHAR(anniversary, 'FMMonth DD') as anniversary_date,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, anniversary)) as years_married
FROM planning_center.people_people
WHERE anniversary IS NOT NULL
AND status = 'active'
AND EXTRACT(MONTH FROM anniversary) = EXTRACT(MONTH FROM CURRENT_DATE)
ORDER BY EXTRACT(DAY FROM anniversary);
```
### People Added by Month
```sql theme={null}
-- Track growth over the last year
SELECT
TO_CHAR(created_at, 'YYYY-MM') as month,
COUNT(*) as people_added,
COUNT(*) FILTER (WHERE child = true) as children_added,
COUNT(*) FILTER (WHERE child = false OR child IS NULL) as adults_added
FROM planning_center.people_people
WHERE created_at >= CURRENT_DATE - INTERVAL '12 months'
AND status = 'active'
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month DESC;
```
### Long-Time Members
```sql theme={null}
-- People who have been in database 5+ years
SELECT
person_id,
name,
membership,
created_at,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, created_at)) as years_in_database
FROM planning_center.people_people
WHERE status = 'active'
AND created_at <= CURRENT_DATE - INTERVAL '5 years'
ORDER BY created_at
LIMIT 100;
```
## Basic Statistics
### Overall Demographics Summary
```sql theme={null}
-- High-level congregation overview
SELECT
COUNT(*) as total_people,
COUNT(*) FILTER (WHERE status = 'active') as active_people,
COUNT(*) FILTER (WHERE membership = 'Member') as members,
COUNT(*) FILTER (WHERE child = true) as children,
COUNT(*) FILTER (WHERE graduation_year IS NOT NULL) as students,
COUNT(*) FILTER (WHERE gender = 'Male') as males,
COUNT(*) FILTER (WHERE gender = 'Female') as females,
ROUND(AVG(EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate))) FILTER (WHERE birthdate IS NOT NULL), 1) as avg_age
FROM planning_center.people_people;
```
### Age Group Distribution
```sql theme={null}
-- Breakdown by age categories
WITH age_groups AS (
SELECT
CASE
WHEN birthdate IS NULL THEN 'Unknown'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 1 THEN 'Infant'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 5 THEN 'Preschool'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 12 THEN 'Elementary'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 18 THEN 'Youth'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 30 THEN 'Young Adult'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 50 THEN 'Adult'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) < 65 THEN 'Middle Age'
ELSE 'Senior'
END as age_group
FROM planning_center.people_people
WHERE status = 'active'
)
SELECT
age_group,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) as percentage
FROM age_groups
GROUP BY age_group
ORDER BY
CASE age_group
WHEN 'Infant' THEN 1
WHEN 'Preschool' THEN 2
WHEN 'Elementary' THEN 3
WHEN 'Youth' THEN 4
WHEN 'Young Adult' THEN 5
WHEN 'Adult' THEN 6
WHEN 'Middle Age' THEN 7
WHEN 'Senior' THEN 8
ELSE 9
END;
```
### Household Statistics
```sql theme={null}
-- Household composition analysis
SELECT
'Total Households' as metric,
COUNT(*)::text as value
FROM planning_center.people_households
UNION ALL
SELECT
'Average Household Size',
ROUND(AVG(member_count), 2)::text
FROM planning_center.people_households
UNION ALL
SELECT
'Single-Person Households',
COUNT(*)::text
FROM planning_center.people_households
WHERE member_count = 1
UNION ALL
SELECT
'Families with Children',
COUNT(DISTINCT h.household_id)::text
FROM planning_center.people_households h
JOIN planning_center.people_households_relationships hhr
ON hhr.household_id = h.household_id AND LOWER(hhr.relationship_type) = 'person'
JOIN planning_center.people_people p ON p.person_id = hhr.relationship_id
WHERE p.child = true
ORDER BY metric;
```
## Tips for Writing Queries
### 1. Always Filter by Status
Filter on `status = 'active'` unless you specifically need inactive people.
```sql theme={null}
SELECT person_id, first_name, last_name
FROM planning_center.people_people
WHERE status = 'active';
```
### 2. Handle NULL Values
Use `COALESCE` for defaults, and check for `NULL` before calculating.
```sql theme={null}
SELECT
person_id,
COALESCE(nickname, first_name) as preferred_name,
birthdate
FROM planning_center.people_people
WHERE birthdate IS NOT NULL;
```
### 3. Case-Insensitive Searches
Lowercase the column so `Smith`, `SMITH`, and `smith` all match.
```sql theme={null}
SELECT person_id, first_name, last_name
FROM planning_center.people_people
WHERE LOWER(last_name) LIKE '%smith%';
```
### 4. Date Calculations
`AGE` gives an interval you can pull years from; subtracting dates gives whole days.
```sql theme={null}
SELECT
person_id,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) as age,
CURRENT_DATE - created_at::date as days_since_added
FROM planning_center.people_people
WHERE birthdate IS NOT NULL;
```
### 5. Use Proper Joins
`LEFT JOIN` when the related record may not exist; `JOIN` when it must.
```sql theme={null}
SELECT
p.person_id,
p.first_name,
p.last_name,
e.address as email
FROM planning_center.people_people p
JOIN planning_center.people_people_relationships pr
ON pr.person_id = p.person_id AND pr.relationship_type = 'Email'
LEFT JOIN planning_center.people_emails e
ON e.email_id = pr.relationship_id AND e.is_primary = true;
```
## Common Issues & Solutions
### Issue: Duplicate people in results
**Solution**: Check your joins and consider using DISTINCT.
### Issue: Missing people who should appear
**Solution**: Verify status = 'active' and check relationship joins.
### Issue: Age calculations returning NULL
**Solution**: Ensure birthdate IS NOT NULL before calculating.
### Issue: Contact info not showing
**Solution**: Use LEFT JOIN for optional data like emails and phones.
## Next Steps
Ready for more complex queries? Continue with:
* [Data Model](/planning-center/people/data-model) - Master joining People tables
* [Advanced Queries](/planning-center/people/advanced-queries) - Complex analysis and reporting
* [Reporting Examples](/planning-center/people/reporting-examples) - Real-world ministry applications
# Planning Center People Data Model
Source: https://docs.getparable.io/planning-center/people/data-model
Complete reference for the Planning Center People entity tables in Parable, covering households, field data, workflows, and their relationships.
This document covers every table in the Planning Center People data model in Parable: all 59 entity tables with full field definitions, and all 39 relationship tables, of which the most frequently queried are described individually below.
## Overview
The People module is the most comprehensive in Planning Center, containing:
* **59 entity tables** - All person, household, workflow, form, messaging, and configuration data
* **39 relationship tables** - Linking entities together following Parable's relationship architecture
## Visual Data Model
The diagram below shows the core entities and their relationships in the People module. Use it as a visual reference while exploring the detailed table definitions below.
### Core Entity Relationships
[Open diagram in new tab →](/diagrams/planning-center/people-data-model-01.svg)
### Key Relationships Explained
**Direct Parent-Child Relationships:**
* Emails, phone numbers, and addresses belong directly to people (via `parent_id`)
* Household relationships link people to households via `people_households_relationships`
* Form submissions, workflow cards, notes link back to people
**Generic Relationship Pattern:**
* Campus associations stored in `people_people_relationships` table
* Relationship type identifies the connection (e.g., `primary_campus`)
**Complex Subsystems:**
* **Workflows**: Multi-step process tracking with cards, steps, and activities
* **Forms**: Dynamic form builder with fields, options, and submissions
* **Lists**: Smart segmentation with results linking people to lists
* **Messaging**: Bidirectional messaging between people
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center People module are in the `planning_center` schema. You MUST prefix all table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.people_people`
❌ INCORRECT: `SELECT * FROM people_people`
### Row Level Security (RLS)
This database uses Row Level Security (RLS) to automatically filter data based on:
* **tenant\_organization\_id**: You only see data for your current organization
* **system\_status**: You only see 'active' records by default
**DO NOT add these filters to your WHERE clause** - they are applied automatically:
* ❌ `WHERE tenant_organization_id = 1` (unnecessary)
* ❌ `WHERE system_status = 'active'` (unnecessary)
The RLS policies ensure you only access data you're authorized to see, making these filters redundant and potentially causing performance issues.
## Complete Table Inventory
### Entity Tables (59 total)
#### Core Person & Contact Tables (10)
* `people_people` - Core person records
* `people_addresses` - Physical addresses
* `people_emails` - Email addresses
* `people_phone_numbers` - Phone numbers
* `people_households` - Family units
* `people_campuses` - Church campus locations
* `people_connected_persons` - Connected person records
* `people_person_apps` - App permissions per person
* `people_social_profiles` - Social media profiles
* `people_spam_email_addresses` - Email filtering
#### Workflow System Tables (8)
* `people_workflows` - Workflow definitions
* `people_workflow_cards` - Workflow instances
* `people_workflow_steps` - Workflow step definitions
* `people_workflow_card_activities` - Workflow activity logs
* `people_workflow_card_notes` - Notes on workflow cards
* `people_workflow_categories` - Workflow categorization
* `people_workflow_shares` - Workflow sharing settings
* `people_workflow_step_assignee_summaries` - Assignee summaries
#### Forms System Tables (6)
* `people_forms` - Form definitions
* `people_form_submissions` - Submitted form data
* `people_form_fields` - Form field definitions
* `people_form_field_options` - Field option definitions
* `people_form_submission_values` - Submitted field values
* `people_form_categories` - Form categorization
#### Custom Fields Tables (6)
* `people_field_definitions` - Custom field schemas
* `people_field_data` - Custom field values
* `people_field_options` - Custom field options
* `people_tabs` - UI tab organization
* `people_conditions` - Conditional logic
* `people_rules` - Business rules
#### Lists & Segmentation Tables (6)
* `people_lists` - Smart lists and segments
* `people_list_categories` - List categorization
* `people_list_results` - People in lists
* `people_list_shares` - List sharing permissions
* `people_list_stars` - Favorited lists
* `people_mailchimpsyncstatus` - Email marketing sync
#### Notes & Documentation Tables (4)
* `people_notes` - Pastoral care notes
* `people_note_categories` - Note categorization
* `people_note_category_shares` - Note sharing
* `people_note_category_subscriptions` - Note subscriptions
#### Messaging System Tables (4)
* `people_messages` - Individual messages
* `people_message_groups` - Message groupings
* `people_custom_senders` - Custom email senders
* `people_carriers` - Phone carriers
#### Import & Data Management Tables (4)
* `people_people_imports` - Import jobs
* `people_people_import_conflicts` - Import conflicts
* `people_people_import_history` - Import audit trail
* `people_person_mergers` - Person merge records
#### Administrative & Reference Tables (11)
* `people_apps` - Application integrations
* `people_background_checks` - Background check records
* `people_grades` - School grade level options (not currently populated)
* `people_inactive_reasons` - Inactivation reasons
* `people_marital_statuses` - Marital status options
* `people_name_prefixes` - Title prefixes
* `people_name_suffixes` - Name suffixes
* `people_organizations` - Organization settings
* `people_reports` - Report definitions
* `people_school_options` - School affiliations
* `people_service_times` - Service schedules
### Relationship Tables (39 total)
* `people_background_checks_relationships`
* `people_conditions_relationships`
* `people_connected_people_relationships`
* `people_custom_senders_relationships`
* `people_emails_relationships`
* `people_field_data_relationships`
* `people_field_definitions_relationships`
* `people_field_options_relationships`
* `people_form_field_options_relationships`
* `people_form_fields_relationships`
* `people_form_submission_values_relationships`
* `people_form_submissions_relationships`
* `people_forms_relationships`
* `people_households_relationships`
* `people_list_categories_relationships`
* `people_list_results_relationships`
* `people_list_shares_relationships`
* `people_lists_relationships`
* `people_message_groups_relationships`
* `people_messages_relationships`
* `people_note_categories_relationships`
* `people_note_category_shares_relationships`
* `people_note_category_subscriptions_relationships`
* `people_notes_relationships`
* `people_people_imports_relationships`
* `people_people_relationships`
* `people_person_apps_relationships`
* `people_person_mergers_relationships`
* `people_phone_numbers_relationships`
* `people_reports_relationships`
* `people_service_times_relationships`
* `people_social_profiles_relationships`
* `people_workflow_card_activities_relationships`
* `people_workflow_card_notes_relationships`
* `people_workflow_cards_relationships`
* `people_workflow_shares_relationships`
* `people_workflow_step_assignee_summaries_relationships`
* `people_workflow_steps_relationships`
* `people_workflows_relationships`
## Complete Table Definitions
### Core Person & Contact Tables
#### people\_people
The main person table containing all individual records.
| Column | Type | Description |
| --------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `person_id` | VARCHAR(64) | Planning Center person ID |
| `avatar` | TEXT | Profile image URL |
| `demographic_avatar_url` | VARCHAR(2048) | Demographic avatar URL |
| `first_name` | VARCHAR(255) | First/given name |
| `name` | VARCHAR(255) | Full display name |
| `status` | VARCHAR(50) | Membership status (active, inactive, etc.) |
| `remote_id` | VARCHAR(64) | External system ID |
| `accounting_administrator` | BOOLEAN | Finance access |
| `anniversary` | DATE | Wedding anniversary |
| `birthdate` | DATE | Date of birth |
| `child` | BOOLEAN | Is a child |
| `given_name` | VARCHAR(255) | Legal first name |
| `grade` | INTEGER | Current school grade, ranging `-5` (pre-K) to `12`. Never NULL — `0` is the default for anyone with no grade set, and sits on hundreds of thousands of adults, so it cannot be distinguished from kindergarten. Filter with `child = true` and treat `0` with care |
| `graduation_year` | INTEGER | Expected graduation year |
| `last_name` | VARCHAR(255) | Family/surname |
| `middle_name` | VARCHAR(255) | Middle name |
| `nickname` | VARCHAR(255) | Preferred name |
| `people_permissions` | VARCHAR(255) | People app permissions |
| `site_administrator` | BOOLEAN | Admin access |
| `gender` | VARCHAR(50) | Gender |
| `inactivated_at` | TIMESTAMP | When marked inactive |
| `medical_notes` | TEXT | Medical information |
| `membership` | VARCHAR(255) | Membership level |
| `created_at` | TIMESTAMP | When person was created |
| `updated_at` | TIMESTAMP | Last update time |
| `can_create_forms` | BOOLEAN | Form creation permission |
| `can_email_lists` | BOOLEAN | Email list permission |
| `directory_shared_info` | JSONB | Directory sharing settings |
| `directory_status` | VARCHAR(50) | Directory visibility |
| `passed_background_check` | BOOLEAN | Background check status |
| `resource_permission_flags` | JSONB | Resource permissions |
| `school_type` | VARCHAR(255) | School type |
| `mfa_configured` | BOOLEAN | Two-factor auth enabled |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_addresses
Physical addresses for people.
| Column | Type | Description |
| ------------------------ | ------------- | ------------------------------- |
| `id` | UUID | Internal unique identifier |
| `address_id` | VARCHAR(64) | Planning Center address ID |
| `city` | VARCHAR(255) | City |
| `country_code` | VARCHAR(2) | Country code |
| `country_name` | VARCHAR(255) | Country name |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `location` | VARCHAR(255) | Address type (home, work, etc.) |
| `is_primary` | BOOLEAN | Primary address flag |
| `state` | VARCHAR(255) | State/Province |
| `street_line_1` | VARCHAR(1024) | Street address line 1 |
| `street_line_2` | VARCHAR(1024) | Street address line 2 |
| `zip` | VARCHAR(255) | Postal code |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_emails
Email addresses for people.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------- |
| `id` | UUID | Internal unique identifier |
| `email_id` | VARCHAR(64) | Planning Center email ID |
| `address` | VARCHAR(255) | Email address |
| `blocked` | BOOLEAN | Email blocked/unsubscribed |
| `created_at` | TIMESTAMP | When created |
| `location` | VARCHAR(50) | Email type (home, work, etc.) |
| `is_primary` | BOOLEAN | Primary email flag |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_phone\_numbers
Phone numbers with carrier information.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------- |
| `id` | UUID | Internal unique identifier |
| `phone_number_id` | VARCHAR(64) | Planning Center phone ID |
| `carrier` | VARCHAR(255) | Phone carrier |
| `country_code` | VARCHAR(2) | Country code |
| `e164` | VARCHAR(255) | E164 formatted number |
| `international` | VARCHAR(255) | International format |
| `location` | VARCHAR(255) | Phone type (mobile, home, etc.) |
| `national` | VARCHAR(255) | National format |
| `number` | VARCHAR(255) | Phone number |
| `is_primary` | BOOLEAN | Primary phone flag |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_households
Family units for grouping related people.
| Column | Type | Description |
| ------------------------ | ------------ | ---------------------------- |
| `id` | UUID | Internal unique identifier |
| `household_id` | VARCHAR(64) | Planning Center household ID |
| `avatar` | TEXT | Household avatar URL |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `member_count` | INTEGER | Number of members |
| `name` | VARCHAR(255) | Household name |
| `primary_contact_name` | VARCHAR(255) | Main contact name |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_campuses
Church campus locations.
| Column | Type | Description |
| -------------------------- | ---------------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `campus_id` | VARCHAR(64) | Planning Center campus ID |
| `avatar_url` | VARCHAR(2048) | Campus image |
| `church_center_enabled` | BOOLEAN | Church Center enabled |
| `city` | VARCHAR(255) | City |
| `contact_email_address` | VARCHAR(255) | Contact email |
| `country` | VARCHAR(255) | Country |
| `created_at` | TIMESTAMP | When created |
| `date_format` | BOOLEAN | Date format setting |
| `description` | TEXT | Campus description |
| `geolocation_set_manually` | BOOLEAN | Manual geolocation flag |
| `latitude` | DOUBLE PRECISION | GPS latitude |
| `longitude` | DOUBLE PRECISION | GPS longitude |
| `name` | VARCHAR(255) | Campus name |
| `phone_number` | VARCHAR(255) | Contact phone |
| `state` | VARCHAR(255) | State |
| `street` | VARCHAR(255) | Street address |
| `time_zone` | VARCHAR(255) | Time zone |
| `time_zone_raw` | VARCHAR(255) | Raw time zone data |
| `twenty_four_hour_time` | BOOLEAN | 24-hour time format |
| `updated_at` | TIMESTAMP | Last update |
| `website` | VARCHAR(255) | Campus website |
| `zip` | VARCHAR(255) | Postal code |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_connected\_persons
Connected person records from external systems.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------- |
| `id` | UUID | Internal unique identifier |
| `connected_person_id` | VARCHAR(64) | Planning Center connected person ID |
| `first_name` | VARCHAR(255) | First name |
| `gender` | VARCHAR(50) | Gender |
| `given_name` | VARCHAR(255) | Legal first name |
| `last_name` | VARCHAR(255) | Last name |
| `middle_name` | VARCHAR(255) | Middle name |
| `nickname` | VARCHAR(255) | Nickname |
| `organization_name` | VARCHAR(255) | Organization name |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_person\_apps
Application permissions per person.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_app_id` | VARCHAR(64) | Planning Center person app ID |
| `allow_pco_login` | BOOLEAN | Can login to PCO |
| `people_permissions` | VARCHAR(255) | Permission level |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_social\_profiles
Social media profiles for people.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------- |
| `id` | UUID | Internal unique identifier |
| `social_profile_id` | VARCHAR(64) | Planning Center social profile ID |
| `created_at` | TIMESTAMP | When created |
| `site` | VARCHAR(255) | Social media platform |
| `updated_at` | TIMESTAMP | Last update |
| `url` | TEXT | Profile URL |
| `verified` | BOOLEAN | Verified account |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_spam\_email\_addresses
Email addresses marked as spam or blocked.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------- |
| `id` | UUID | Internal unique identifier |
| `spam_email_address_id` | VARCHAR(64) | Planning Center spam email ID |
| `address` | VARCHAR(255) | Email address |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
### Workflow System Tables
#### people\_workflows
Process workflow definitions for managing people through various church processes.
| Column | Type | Description |
| ------------------------------------ | ------------ | ------------------------------- |
| `id` | UUID | Internal unique identifier |
| `workflow_id` | VARCHAR(64) | Planning Center workflow ID |
| `completed_card_count` | INTEGER | Completed cards count |
| `created_at` | TIMESTAMP | When created |
| `deleted_at` | TIMESTAMP | Soft deletion |
| `my_due_soon_card_count` | INTEGER | Due soon cards for current user |
| `my_overdue_card_count` | INTEGER | Overdue cards for current user |
| `my_ready_card_count` | INTEGER | Ready cards for current user |
| `name` | VARCHAR(255) | Workflow name |
| `recently_viewed` | BOOLEAN | Recently accessed flag |
| `total_cards_count` | INTEGER | Total cards in workflow |
| `total_ready_and_snoozed_card_count` | INTEGER | Ready and snoozed count |
| `total_ready_card_count` | INTEGER | Total ready cards |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_workflow\_cards
Individual workflow instances tracking people through processes.
**Note**: Workflow card relationships (assignee, current\_step, person, workflow) are stored in the `people_workflow_cards_relationships` table, not as direct foreign key columns. See the Relationships section below.
| Column | Type | Description |
| ------------------------------- | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `workflow_card_id` | VARCHAR(64) | Planning Center card ID |
| `calculated_due_at_in_days_ago` | INTEGER | Due date calculation |
| `completed_at` | TIMESTAMP | Completion time |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `flagged_for_notification_at` | TIMESTAMP | Notification flag |
| `moved_to_step_at` | TIMESTAMP | Last step transition |
| `overdue` | BOOLEAN | Overdue status |
| `removed_at` | TIMESTAMP | Removal time |
| `snooze_until` | TIMESTAMP | Snooze end time |
| `stage` | VARCHAR(255) | Current stage |
| `sticky_assignment` | BOOLEAN | Assignment persistence |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_workflow\_steps
Workflow step definitions.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `workflow_step_id` | VARCHAR(64) | Planning Center step ID |
| `auto_snooze_days` | INTEGER | Auto-snooze duration |
| `auto_snooze_value` | INTEGER | Snooze value |
| `auto_snooze_interval` | VARCHAR(50) | Snooze interval type |
| `created_at` | TIMESTAMP | When created |
| `description` | TEXT | Step description |
| `name` | VARCHAR(255) | Step name |
| `sequence` | INTEGER | Step order |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_workflow\_card\_activities
Activity logs for workflow cards.
| Column | Type | Description |
| --------------------------- | ------------- | --------------------------- |
| `id` | UUID | Internal unique identifier |
| `workflow_card_activity_id` | VARCHAR(64) | Planning Center activity ID |
| `comment` | TEXT | Activity comment |
| `created_at` | TIMESTAMP | When created |
| `person_avatar_url` | VARCHAR(2048) | Person avatar |
| `person_name` | VARCHAR(255) | Person name |
| `reassigned_to_avatar_url` | VARCHAR(2048) | Reassigned person avatar |
| `reassigned_to_name` | VARCHAR(255) | Reassigned person name |
| `subject` | VARCHAR(255) | Activity subject |
| `type` | VARCHAR(100) | Activity type |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_workflow\_card\_notes
Notes attached to workflow cards.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `workflow_card_note_id` | VARCHAR(64) | Planning Center note ID |
| `created_at` | TIMESTAMP | When created |
| `note` | TEXT | Note content |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_workflow\_categories
Categories for organizing workflows.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------- |
| `id` | UUID | Internal unique identifier |
| `workflow_category_id` | VARCHAR(64) | Planning Center category ID |
| `created_at` | TIMESTAMP | When created |
| `name` | VARCHAR(255) | Category name |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_workflow\_shares
Workflow sharing permissions.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `workflow_share_id` | VARCHAR(64) | Planning Center share ID |
| `permission` | VARCHAR(100) | Permission level |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_workflow\_step\_assignee\_summaries
Summary of workflow step assignments.
| Column | Type | Description |
| ----------------------------------- | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `workflow_step_assignee_summary_id` | VARCHAR(64) | Planning Center summary ID |
| `snoozed_count` | INTEGER | Cards currently snoozed |
| `ready_count` | INTEGER | Cards ready for action |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
### Forms System Tables
#### people\_forms
Form definitions for data collection.
| Column | Type | Description |
| ------------------------------------------- | ------------ | ------------------------------- |
| `id` | UUID | Internal unique identifier |
| `form_id` | VARCHAR(64) | Planning Center form ID |
| `active` | BOOLEAN | Active status |
| `archived` | BOOLEAN | Archived status |
| `archived_at` | TIMESTAMP | Archive timestamp |
| `created_at` | TIMESTAMP | When created |
| `deleted_at` | TIMESTAMP | Soft deletion |
| `description` | TEXT | Form description |
| `login_required` | BOOLEAN | Login required to submit |
| `name` | VARCHAR(255) | Form name |
| `public_url` | VARCHAR(255) | Public form URL |
| `recently_viewed` | BOOLEAN | Recently accessed |
| `send_submission_notification_to_submitter` | BOOLEAN | Email notification to submitter |
| `submission_count` | INTEGER | Number of submissions |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_form\_submissions
Submitted form data.
| Column | Type | Description |
| ------------------------ | ----------- | ----------------------------- |
| `id` | UUID | Internal unique identifier |
| `form_submission_id` | VARCHAR(64) | Planning Center submission ID |
| `created_at` | TIMESTAMP | When submitted |
| `verified` | BOOLEAN | Verification status |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_form\_fields
Form field definitions.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `form_field_id` | VARCHAR(64) | Planning Center field ID |
| `created_at` | TIMESTAMP | When created |
| `description` | TEXT | Field description |
| `label` | VARCHAR(255) | Field label |
| `required` | BOOLEAN | Required field |
| `sequence` | INTEGER | Field order |
| `settings` | JSONB | Field settings |
| `field_type` | VARCHAR(100) | Field type |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_form\_field\_options
Options for form fields.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `form_field_option_id` | VARCHAR(64) | Planning Center option ID |
| `label` | VARCHAR(255) | Option label |
| `sequence` | INTEGER | Option order |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_form\_submission\_values
Values submitted for form fields.
| Column | Type | Description |
| -------------------------- | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `form_submission_value_id` | VARCHAR(64) | Planning Center value ID |
| `display_value` | TEXT | Display value |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_form\_categories
Categories for organizing forms.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------- |
| `id` | UUID | Internal unique identifier |
| `form_category_id` | VARCHAR(64) | Planning Center category ID |
| `name` | VARCHAR(255) | Category name |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
### Custom Fields Tables
#### people\_field\_definitions
Custom field schemas.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------- |
| `id` | UUID | Internal unique identifier |
| `field_definition_id` | VARCHAR(64) | Planning Center field definition ID |
| `config` | JSONB | Field configuration |
| `data_type` | VARCHAR(50) | Data type |
| `deleted_at` | TIMESTAMP | Soft deletion |
| `name` | VARCHAR(255) | Field name |
| `sequence` | INTEGER | Field order |
| `slug` | VARCHAR(255) | URL slug |
| `field_options` | JSONB | Field options configuration |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_field\_data
Custom field values for people.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------- |
| `id` | UUID | Internal unique identifier |
| `field_data_id` | VARCHAR(64) | Planning Center field data ID |
| `file` | TEXT | File attachment URL |
| `file_content_type` | VARCHAR(255) | File MIME type |
| `file_name` | TEXT | File name |
| `file_size` | INTEGER | File size in bytes |
| `value` | TEXT | Field value |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_field\_options
Options for custom fields.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `field_option_id` | VARCHAR(64) | Planning Center option ID |
| `sequence` | INTEGER | Option order |
| `value` | VARCHAR(255) | Option value |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_tabs
UI tab organization for custom fields.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `tab_id` | VARCHAR(64) | Planning Center tab ID |
| `name` | VARCHAR(255) | Tab name |
| `slug` | VARCHAR(255) | URL slug |
| `sequence` | INTEGER | Tab order |
| `field_definitions` | JSONB | Field definitions in tab |
| `field_options` | JSONB | Field options |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_conditions
Conditional logic for fields and workflows.
| Column | Type | Description |
| ------------------------ | ------------ | ---------------------------- |
| `id` | UUID | Internal unique identifier |
| `condition_id` | VARCHAR(64) | Planning Center condition ID |
| `application` | VARCHAR(255) | Application context |
| `comparison` | VARCHAR(255) | Comparison operator |
| `created_at` | TIMESTAMP | When created |
| `definition_class` | VARCHAR(255) | Definition class |
| `definition_identifier` | VARCHAR(255) | Definition identifier |
| `description` | VARCHAR(255) | Condition description |
| `settings` | JSONB | Condition settings |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_rules
Business rules for data processing.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `rule_id` | VARCHAR(64) | Planning Center rule ID |
| `created_at` | TIMESTAMP | When created |
| `subset` | VARCHAR(255) | Rule subset |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
### Lists & Segmentation Tables
#### people\_lists
Smart lists for segmenting people.
| Column | Type | Description |
| -------------------------- | ------------ | ---------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `list_id` | VARCHAR(64) | Planning Center list ID |
| `auto_refresh` | BOOLEAN | Whether the list refreshes automatically |
| `automations_active` | BOOLEAN | Automations enabled |
| `automations_count` | INTEGER | Number of automations |
| `batch_completed_at` | TIMESTAMP | Last batch completion |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `description` | TEXT | List description |
| `has_inactive_results` | BOOLEAN | Includes inactive people |
| `invalid` | BOOLEAN | List has errors |
| `name` | VARCHAR(255) | List name |
| `name_or_description` | TEXT | Combined name/description |
| `paused_automations_count` | INTEGER | Paused automation count |
| `recently_viewed` | BOOLEAN | Recently accessed |
| `refreshed_at` | TIMESTAMP | Last refresh |
| `returns` | VARCHAR(100) | Return type |
| `starred` | BOOLEAN | Is favorited |
| `status` | VARCHAR(50) | List status |
| `subset` | VARCHAR(100) | List subset |
| `total_people` | INTEGER | People count |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_list\_categories
Categories for organizing lists.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------- |
| `id` | UUID | Internal unique identifier |
| `list_category_id` | VARCHAR(64) | Planning Center category ID |
| `created_at` | TIMESTAMP | When created |
| `name` | VARCHAR(255) | Category name |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_list\_results
People included in lists.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `list_result_id` | VARCHAR(64) | Planning Center result ID |
| `created_at` | TIMESTAMP | When added to list |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_list\_shares
List sharing permissions.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `list_share_id` | VARCHAR(64) | Planning Center share ID |
| `created_at` | TIMESTAMP | When shared |
| `permission_group` | VARCHAR(255) | Share group |
| `name` | VARCHAR(255) | Share name |
| `permission` | VARCHAR(100) | Permission level |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_list\_stars
Favorited lists per user.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `list_star_id` | VARCHAR(64) | Planning Center star ID |
| `created_at` | TIMESTAMP | When favorited |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_mailchimpsyncstatus
Email marketing synchronization status.
| Column | Type | Description |
| -------------------------- | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `mailchimp_sync_status_id` | VARCHAR(64) | Planning Center sync ID |
| `completed_at` | TIMESTAMP | When sync completed |
| `error` | TEXT | Error message |
| `segment_id` | VARCHAR(255) | Mailchimp segment |
| `status` | VARCHAR(100) | Sync status |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
### Notes & Documentation Tables
#### people\_notes
Pastoral care notes and follow-ups.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `note_id` | VARCHAR(64) | Planning Center note ID |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `note` | TEXT | Note content |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_note\_categories
Categories for organizing notes.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------- |
| `id` | UUID | Internal unique identifier |
| `note_category_id` | VARCHAR(64) | Planning Center category ID |
| `created_at` | TIMESTAMP | When created |
| `locked` | BOOLEAN | Category locked |
| `name` | VARCHAR(255) | Category name |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_note\_category\_shares
Note category sharing permissions.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `note_category_share_id` | VARCHAR(64) | Planning Center share ID |
| `permission_group` | VARCHAR(255) | Share group |
| `permission` | VARCHAR(100) | Permission level |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_note\_category\_subscriptions
Subscriptions to note categories.
| Column | Type | Description |
| ------------------------------- | ----------- | ------------------------------- |
| `id` | UUID | Internal unique identifier |
| `note_category_subscription_id` | VARCHAR(64) | Planning Center subscription ID |
| `created_at` | TIMESTAMP | When subscribed |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
### Messaging System Tables
#### people\_messages
Individual messages sent through the system.
| Column | Type | Description |
| -------------------------------- | ------------ | --------------------------- |
| `id` | UUID | Internal unique identifier |
| `message_id` | VARCHAR(64) | Planning Center message ID |
| `app_name` | VARCHAR(255) | Originating app |
| `created_at` | TIMESTAMP | When created |
| `delivery_status` | VARCHAR(100) | Delivery status |
| `from_address` | JSONB | Sender address |
| `message_type` | VARCHAR(100) | Message type |
| `reject_reason` | TEXT | Rejection reason |
| `rejection_notification_sent_at` | TIMESTAMP | Rejection notification time |
| `sent_at` | TIMESTAMP | When sent |
| `subject` | TEXT | Message subject |
| `to_addresses` | JSONB | Recipient addresses |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_message\_groups
Groupings of related messages.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `message_group_id` | VARCHAR(64) | Planning Center group ID |
| `created_at` | TIMESTAMP | When created |
| `from_address` | VARCHAR(255) | Sender address |
| `message_count` | INTEGER | Number of messages |
| `message_type` | VARCHAR(100) | Message type |
| `subject` | TEXT | Group subject |
| `uuid` | VARCHAR(255) | External UUID |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_custom\_senders
Custom email sender configurations.
| Column | Type | Description |
| --------------------------- | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `custom_sender_id` | VARCHAR(64) | Planning Center sender ID |
| `name` | VARCHAR(255) | Sender name |
| `email_address` | VARCHAR(255) | Email address |
| `verified_at` | TIMESTAMP | Verification time |
| `verification_requested_at` | TIMESTAMP | Verification request time |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `verified` | BOOLEAN | Verification status |
| `expired` | BOOLEAN | Expiration status |
| `verification_status` | VARCHAR(255) | Verification status |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_carriers
Phone carrier information for SMS messaging.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `carrier_id` | VARCHAR(64) | Planning Center carrier ID |
| `international` | BOOLEAN | International carrier |
| `name` | VARCHAR(255) | Carrier name |
| `value` | VARCHAR(255) | Carrier value |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
### Import & Data Management Tables
#### people\_people\_imports
Import job definitions.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------- |
| `id` | UUID | Internal unique identifier |
| `people_import_id` | VARCHAR(64) | Planning Center import ID |
| `processed_at` | TIMESTAMP | When the import finished processing |
| `created_at` | TIMESTAMP | When created |
| `processed_at` | TIMESTAMP | When processed |
| `status` | VARCHAR(100) | Import status |
| `undone_at` | TIMESTAMP | When undone |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_people\_import\_conflicts
Conflicts detected during imports.
| Column | Type | Description |
| --------------------------- | ------------ | --------------------------- |
| `id` | UUID | Internal unique identifier |
| `people_import_conflict_id` | VARCHAR(64) | Planning Center conflict ID |
| `conflicting_changes` | JSONB | Conflicting changes |
| `created_at` | TIMESTAMP | When created |
| `data` | JSONB | Conflict data |
| `ignore` | BOOLEAN | Ignore conflict |
| `kind` | VARCHAR(100) | Conflict type |
| `message` | TEXT | Conflict message |
| `name` | VARCHAR(255) | Conflict name |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_people\_import\_history
Audit trail of import operations.
| Column | Type | Description |
| -------------------------- | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `people_import_history_id` | VARCHAR(64) | Planning Center history ID |
| `conflicting_changes` | JSONB | Conflicting changes |
| `created_at` | TIMESTAMP | When created |
| `name` | VARCHAR(255) | History name |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_person\_mergers
Records of merged person records.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_merger_id` | VARCHAR(64) | Planning Center merger ID |
| `created_at` | TIMESTAMP | When merged |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
### Administrative & Reference Tables
#### people\_apps
Application integrations available.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `app_id` | VARCHAR(64) | Planning Center app ID |
| `name` | VARCHAR(255) | App name |
| `url` | TEXT | App URL |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_background\_checks
Background check records for volunteers and staff.
| Column | Type | Description |
| ------------------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `background_check_id` | VARCHAR(64) | Planning Center check ID |
| `completed_at` | TIMESTAMP | When completed |
| `current` | BOOLEAN | Current status |
| `expires_on` | DATE | Expiration date |
| `note` | TEXT | Check notes |
| `report_url` | TEXT | Report URL |
| `status` | VARCHAR(255) | Planning Center check status — `manual_clear`, `report_clear`, `complete_clear` (passes), `manual_not_clear` / `complete_not_clear` (fails), plus in-flight values like `invitation_pending`, `awaiting_applicant`, `report_processing`, `report_consider`. There is no `'passed'` value |
| `status_updated_at` | TIMESTAMP | Status update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_grades
School grade level options.
This table is created and queryable, but is **not currently populated** — it
holds zero rows in every organization. Do not build a report that joins to
`people_grades`; it will return no rows. Read the numeric
`people_people.grade` column instead, keeping its `0` caveat in mind.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `grade_id` | VARCHAR(64) | Planning Center grade ID |
| `key` | INTEGER | Sort order for the grade |
| `value` | VARCHAR(255) | Grade label |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_inactive\_reasons
Reasons for marking people inactive.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `inactive_reason_id` | VARCHAR(64) | Planning Center reason ID |
| `value` | VARCHAR(255) | Reason value |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_marital\_statuses
Marital status options.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `marital_status_id` | VARCHAR(64) | Planning Center status ID |
| `value` | VARCHAR(255) | Status value |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_name\_prefixes
Title prefixes (Mr., Mrs., Dr., etc.).
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `name_prefix_id` | VARCHAR(64) | Planning Center prefix ID |
| `value` | VARCHAR(255) | Prefix value |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_name\_suffixes
Name suffixes (Jr., Sr., III, etc.).
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `name_suffix_id` | VARCHAR(64) | Planning Center suffix ID |
| `value` | VARCHAR(255) | Suffix value |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_organizations
Organization configuration and settings.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `organization_id` | VARCHAR(64) | Planning Center org ID |
| `name` | VARCHAR(255) | Organization name |
| `country_code` | VARCHAR(10) | Country code |
| `date_format` | VARCHAR(50) | Date format preference |
| `time_zone` | VARCHAR(100) | Time zone |
| `contact_website` | VARCHAR(255) | Contact website |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_reports
Report definitions for data analysis.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `report_id` | VARCHAR(64) | Planning Center report ID |
| `body` | TEXT | Report body |
| `created_at` | TIMESTAMP | When created |
| `name` | VARCHAR(255) | Report name |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_school\_options
School affiliations and options.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `school_option_id` | VARCHAR(64) | Planning Center school ID |
| `beginning_grade` | VARCHAR(50) | Starting grade |
| `ending_grade` | VARCHAR(50) | Ending grade |
| `school_types` | JSONB | School types |
| `sequence` | INTEGER | Sort order |
| `value` | VARCHAR(255) | School name |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
#### people\_service\_times
Service schedule information.
| Column | Type | Description |
| ------------------------ | ------------ | ---------------------------------- |
| `id` | UUID | Internal unique identifier |
| `service_time_id` | VARCHAR(64) | Planning Center service time ID |
| `day` | VARCHAR(255) | Day of week |
| `description` | TEXT | Service description |
| `start_time` | INTEGER | Start time (minutes from midnight) |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | When updated in Parable |
## Relationship Tables
All relationship tables follow a similar pattern for linking entities:
### Standard Relationship Table Structure
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `{entity}_id` | VARCHAR(64) | Parent entity ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created |
| `system_updated_at` | TIMESTAMP | Last update |
### Key Relationship Tables
All 39 relationship tables are listed in the [inventory above](#relationship-tables-39-total) and every one of them follows the standard structure. The ten described below are the ones most often needed in reports; consult them for the relationship types each table carries.
#### people\_people\_relationships
Links people to campuses, lists, inactive reasons, marital statuses, organizations, etc.
#### people\_households\_relationships
Links households to people and campuses.
**IMPORTANT**: This table stores relationships between households and people. The primary contact for a household is stored in the `people_households` table via the `primary_contact_name` column, and the relationship is available through `people_households_relationships`.
**Relationship Design Decision**:
* Planning Center's API returns both `primary_contact` and `people` relationships for each household
* The `primary_contact` person is always included in the `people` array
* This table stores both `People` and `PrimaryContact` relationship types
* Use `relationship_type = 'PrimaryContact'` to find the primary contact person for a household
**Example Query**:
```sql theme={null}
-- Get primary contact name for a household
SELECT primary_contact_name
FROM planning_center.people_households
WHERE household_id = '22041396';
-- Get all people in a household (including primary contact)
SELECT relationship_id, relationship_type
FROM planning_center.people_households_relationships
WHERE household_id = '22041396';
```
#### people\_emails\_relationships
Links emails to people.
#### people\_phone\_numbers\_relationships
Links phone numbers to people.
#### people\_field\_data\_relationships
Links custom field data to people and other entities.
#### people\_forms\_relationships
Links forms to campuses and categories.
#### people\_workflows\_relationships
Links workflows to campuses and categories.
#### people\_workflow\_cards\_relationships
Links workflow cards to people (assignees and subjects), workflow steps, and workflows.
#### people\_messages\_relationships
Links messages to people and message groups.
#### people\_notes\_relationships
Links notes to people and note categories.
## System Fields
All tables include these system fields for data management:
* `tenant_organization_id` - Multi-tenant organization identifier
* `system_status` - Data lifecycle status:
* `transferring` - Being imported from Planning Center
* `active` - Current active data
* `stale` - Marked for removal
* `system_created_at` - When record was created in Parable
* `system_updated_at` - When record was last updated in Parable
## Data Integrity Rules
1. **Schema Qualification**: Always use `planning_center.` prefix for all table references
2. **Row Level Security**: RLS automatically handles multi-tenancy and status filtering - do not add manual filters
3. **Monetary Values**: People tables that include contribution amounts store them in cents - divide by 100.0 for display
4. **Contact Status Flags**: Use fields like `status` and `primary` to interpret person records instead of relying on `system_status`
5. **Direct ID Columns**: Core tables such as `people_people`, `people_households`, and `people_lists` expose direct IDs for performance-sensitive joins
## Common Mistakes to Avoid
1. **Missing Schema Prefix**
* ❌ `FROM people_people`
* ✅ `FROM planning_center.people_people`
2. **Adding Redundant RLS Filters**
* ❌ `WHERE tenant_organization_id = 1 AND system_status = 'active'`
* ✅ Trust RLS to handle this automatically
3. **Joining Without Schema**
* ❌ `JOIN people_households h ON ...`
* ✅ `JOIN planning_center.people_households h ON ...`
4. **Skipping Currency Conversion**
* ❌ `SELECT amount_cents as amount`
* ✅ `SELECT amount_cents / 100.0 as amount`
## Performance Considerations
1. **Indexes**: All tables have optimized indexes on:
* Primary keys and entity IDs
* Join columns and foreign keys
* Date columns for time-based queries
2. **Query Optimization**:
* Always use the `planning_center.` schema prefix when querying planning center tables.
* RLS handles tenant and status filtering automatically
* Filter person status or inactivity flags when relevant
* Consider CTEs for complex multi-join queries
* Join through the `*_relationships` tables — People tables do not carry
foreign-key columns to other entities
## Verification Summary
✅ **59 entity tables — all listed in the inventory, all with field definitions**
✅ **39 relationship tables — all listed in the inventory; 10 described individually, the rest follow the [standard structure](#standard-relationship-table-structure)**
✅ **All tables include the system columns described above**
✅ **Consistent formatting with other modules**
Counts on this page were verified against the `planning_center` schema itself, and cover the complete Planning Center People data model in Parable.
# Planning Center People SQL Queries
Source: https://docs.getparable.io/planning-center/people/overview
Query Planning Center People data with SQL to understand your congregation: households, demographics, custom fields, and engagement across ministries.
## Know Your Congregation Through Data
Your church database is the foundation of ministry. With Parable's SQL access to Planning Center People data, you can understand your congregation deeply, track engagement, manage pastoral care, and ensure no one is overlooked.
## Quick Start
Ready to explore your people data? Here's your first query to see recent additions to your church:
```sql theme={null}
-- See the 10 most recently added people
SELECT
person_id,
first_name,
last_name,
name,
status,
membership,
created_at,
CASE
WHEN child = true THEN 'Child'
WHEN graduation_year IS NOT NULL THEN 'Student'
ELSE 'Adult'
END as age_group
FROM planning_center.people_people
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 10;
```
## What You Can Do With People Queries
### 👥 Understand Your Congregation
* Track membership growth and demographics
* Identify family units and relationships
* Analyze age distributions and life stages
* Monitor geographic spread of your church
### 📊 Measure Engagement
* Track attendance patterns across ministries
* Identify highly engaged vs occasional attendees
* Find people not connected to any groups or serving teams
* Monitor volunteer participation
### 🎯 Pastoral Care
* Identify people needing follow-up
* Track milestones (birthdays, anniversaries)
* Monitor spiritual journey progress
* Manage background checks and safety protocols
### 📈 Strategic Planning
* Demographic analysis for ministry planning
* Campus and location insights
* Communication preferences analysis
* Volunteer capacity planning
## Available Tables
Your Planning Center People data is organized into these primary tables:
| Table | What It Contains | Key Use Cases |
| -------------------------- | ------------------- | ---------------------------------------------- |
| `people_people` | Core person records | Demographics, status, membership, permissions |
| `people_households` | Family units | Family groupings, primary contacts |
| `people_emails` | Email addresses | Contact info, primary emails, communication |
| `people_phone_numbers` | Phone numbers | Contact info, SMS capability |
| `people_addresses` | Physical addresses | Mailing, geographic analysis, home visits |
| `people_campuses` | Church locations | Multi-site management, campus assignment |
| `people_lists` | Custom people lists | Segmentation, targeted ministry |
| `people_field_data` | Custom field values | Additional data points, ministry-specific info |
| `people_notes` | Pastoral notes | Care tracking, prayer requests, follow-ups |
| `people_workflows` | Process workflows | New member classes, volunteer onboarding |
| `people_workflow_cards` | Workflow progress | Individual progress through processes |
| `people_forms` | Church forms | Sign-ups, registrations, information gathering |
| `people_form_submissions` | Form responses | Submitted data, event registrations |
| `people_background_checks` | Safety screening | Volunteer clearance, child safety |
## Understanding Relationships
Parable stores Planning Center relationships in separate tables to maintain data integrity. Key relationship patterns include:
* `people_people_relationships` - Links people to campuses, lists, inactive reasons
* `people_households_relationships` - Links households to people and campuses
* `people_emails_relationships` - Links emails to people
* `people_phone_numbers_relationships` - Links phone numbers to people
* `people_field_data_relationships` - Links custom field data to people
We'll show you exactly how to join these tables in our examples!
## Key Concepts
### Person Status
* `active` - Current member/attendee
* `inactive` - No longer attending
* Other custom statuses your church defines
### Membership Levels
Your church defines membership levels like:
* `Member` - Full members
* `Regular Attender` - Non-members who attend regularly
* `Visitor` - Occasional attendees
* Custom levels specific to your church
### Age Groups
* `child` - Boolean flag for children
* `graduation_year` - Indicates students
* `birthdate` - For age calculations
* `grade` - Current school grade
### Permissions
* `site_administrator` - Full system access
* `people_permissions` - Access to People app
* `can_create_forms` - Form creation rights
* `can_email_lists` - Mass email permissions
## Next Steps
📚 **New to SQL?** Start with [Basic Queries](/planning-center/people/basic-queries) for simple, powerful queries you can use today.
🚀 **Ready for More?** Check out [Advanced Queries](/planning-center/people/advanced-queries) for complex analysis and reporting.
📊 **Need Reports?** See [Reporting Examples](/planning-center/people/reporting-examples) for complete, production-ready reports.
🔍 **Want Details?** Review the [Data Model](/planning-center/people/data-model) for complete table documentation.
## Common Questions
### How do I find a specific person?
```sql theme={null}
SELECT * FROM planning_center.people_people
WHERE LOWER(first_name) LIKE '%john%'
OR LOWER(last_name) LIKE '%smith%'
AND status = 'active';
```
### What's the difference between name fields?
* `name` - Full display name
* `first_name` - First/given name
* `last_name` - Family/surname
* `nickname` - Preferred name
* `middle_name` - Middle name
* `given_name` - Legal first name
### How do I calculate age from birthdate?
```sql theme={null}
SELECT
name,
birthdate,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) as age
FROM planning_center.people_people
WHERE birthdate IS NOT NULL;
```
### How do I find household members?
Join through the household relationships table:
```sql theme={null}
SELECT
h.name as household_name,
p.name as person_name
FROM planning_center.people_households h
JOIN planning_center.people_households_relationships hr
ON hr.household_id = h.household_id
AND hr.relationship_type = 'Person'
JOIN planning_center.people_people p
ON p.person_id = hr.relationship_id
WHERE h.household_id = 'YOUR_HOUSEHOLD_ID';
```
### What does status = 'active' mean?
Active people are current participants in your church. Inactive people have been marked as no longer attending (moved, deceased, etc.). Always filter by status unless you specifically need inactive records.
## Tips for Success
1. **Filter by Status** - Usually include `WHERE status = 'active'`
2. **Handle NULLs** - Many fields are optional, use `COALESCE` or `IS NOT NULL`
3. **Use Relationships** - Join through relationship tables for connected data
4. **Consider Privacy** - Be mindful of sensitive data like medical notes
5. **Test with LIMIT** - Add `LIMIT 10` while developing queries
## Getting Help
* 🐛 Found an issue? Report it at [github.com/getparable/parable-api/issues](https://github.com/getparable/parable-api/issues)
* 📖 Need more examples? Check our other query guides in this folder
* 💬 Have questions? Reach out to your Parable support team
***
*Every person matters. Let data help you care for them better.*
# Planning Center People Report Examples
Source: https://docs.getparable.io/planning-center/people/reporting-examples
Production-ready People reports for church leadership: membership lists, new-visitor follow-up, and congregation demographics ready to run.
This guide provides complete, production-ready SQL reports for Planning Center People data. These reports are designed to be run regularly for leadership meetings, pastoral care, and strategic planning.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center People module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your reports.
✅ CORRECT: `SELECT * FROM planning_center.people_people`
❌ INCORRECT: `SELECT * FROM people_people`
### Row Level Security (RLS)
Row Level Security automatically filters results for:
* **tenant\_organization\_id** – only your organization's data
* **system\_status** – active records returned by default
**Do not add these filters manually**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Keep your WHERE clauses focused on ministry-specific segments (status, membership, demographics) while relying on RLS for tenancy and system status.
## Executive Dashboard Report
### Weekly Church Membership Summary
```sql theme={null}
-- Executive summary report for church leadership
WITH current_stats AS (
SELECT
COUNT(DISTINCT CASE WHEN status = 'active' THEN person_id END) as active_members,
COUNT(DISTINCT CASE WHEN status = 'active' AND membership = 'Member' THEN person_id END) as full_members,
COUNT(DISTINCT CASE WHEN status = 'active' AND membership = 'Regular Attender' THEN person_id END) as regular_attenders,
COUNT(DISTINCT CASE WHEN status = 'active' AND membership = 'Visitor' THEN person_id END) as visitors,
COUNT(DISTINCT CASE WHEN created_at >= DATE_TRUNC('week', CURRENT_DATE) THEN person_id END) as new_this_week,
COUNT(DISTINCT CASE WHEN child = true AND status = 'active' THEN person_id END) as children,
COUNT(DISTINCT CASE WHEN graduation_year IS NOT NULL AND status = 'active' THEN person_id END) as students,
COUNT(DISTINCT CASE WHEN birthdate IS NOT NULL AND EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) >= 65 AND status = 'active' THEN person_id END) as seniors
FROM planning_center.people_people
),
previous_week AS (
SELECT
COUNT(DISTINCT CASE WHEN status = 'active' THEN person_id END) as active_members
FROM planning_center.people_people
WHERE created_at < DATE_TRUNC('week', CURRENT_DATE)
),
household_stats AS (
SELECT
COUNT(DISTINCT h.household_id) as total_households,
AVG(h.member_count) as avg_household_size
FROM planning_center.people_households h
)
SELECT
'=== WEEKLY MEMBERSHIP EXECUTIVE SUMMARY ===' as report_header,
TO_CHAR(DATE_TRUNC('week', CURRENT_DATE), 'FMMonth DD, YYYY') as week_beginning,
'' as blank1,
'--- MEMBERSHIP METRICS ---' as section1,
cs.active_members as total_active_members,
pw.active_members as active_members_last_week,
cs.active_members - pw.active_members as net_change,
cs.new_this_week as new_people_this_week,
'' as blank2,
'--- MEMBERSHIP BREAKDOWN ---' as section2,
cs.full_members as members,
cs.regular_attenders as regular_attenders,
cs.visitors as visitors,
ROUND((cs.full_members::NUMERIC / NULLIF(cs.active_members, 0)) * 100, 1) as member_percentage,
'' as blank3,
'--- DEMOGRAPHICS ---' as section3,
cs.children as children_count,
cs.students as student_count,
cs.seniors as senior_count,
cs.active_members - cs.children - cs.students as adult_count,
'' as blank4,
'--- HOUSEHOLD METRICS ---' as section4,
hs.total_households as households,
ROUND(hs.avg_household_size, 1) as avg_household_size
FROM current_stats cs, previous_week pw, household_stats hs;
```
### Monthly Demographic Analysis Report
```sql theme={null}
-- Comprehensive demographic breakdown for strategic planning
WITH age_demographics AS (
SELECT
CASE
WHEN child = true THEN '0-12 (Children)'
WHEN graduation_year IS NOT NULL AND graduation_year >= EXTRACT(YEAR FROM CURRENT_DATE) THEN '13-18 (Students)'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) BETWEEN 18 AND 24 THEN '18-24 (College)'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) BETWEEN 25 AND 34 THEN '25-34 (Young Adult)'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) BETWEEN 35 AND 44 THEN '35-44 (Young Family)'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) BETWEEN 45 AND 54 THEN '45-54 (Middle Age)'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) BETWEEN 55 AND 64 THEN '55-64 (Pre-Retirement)'
WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) >= 65 THEN '65+ (Senior)'
ELSE 'Age Unknown'
END as age_group,
gender,
membership,
COUNT(*) as count
FROM planning_center.people_people
WHERE status = 'active'
GROUP BY age_group, gender, membership
),
demographic_summary AS (
SELECT
age_group,
SUM(count) as total,
SUM(CASE WHEN gender = 'Male' THEN count ELSE 0 END) as male_count,
SUM(CASE WHEN gender = 'Female' THEN count ELSE 0 END) as female_count,
SUM(CASE WHEN membership = 'Member' THEN count ELSE 0 END) as members,
SUM(CASE WHEN membership = 'Regular Attender' THEN count ELSE 0 END) as regular_attenders,
SUM(CASE WHEN membership = 'Visitor' THEN count ELSE 0 END) as visitors
FROM age_demographics
GROUP BY age_group
)
SELECT
age_group,
total,
male_count,
female_count,
CASE
WHEN (male_count + female_count) > 0
THEN ROUND(male_count::NUMERIC / (male_count + female_count) * 100, 0)
ELSE NULL
END as male_percentage,
members,
regular_attenders,
visitors,
ROUND((total::NUMERIC / (SELECT SUM(total) FROM demographic_summary)) * 100, 1) as percent_of_church
FROM demographic_summary
ORDER BY
CASE
WHEN age_group LIKE '0-12%' THEN 1
WHEN age_group LIKE '13-18%' THEN 2
WHEN age_group LIKE '18-24%' THEN 3
WHEN age_group LIKE '25-34%' THEN 4
WHEN age_group LIKE '35-44%' THEN 5
WHEN age_group LIKE '45-54%' THEN 6
WHEN age_group LIKE '55-64%' THEN 7
WHEN age_group LIKE '65+%' THEN 8
ELSE 9
END;
```
## Pastoral Care Reports
### Birthday and Anniversary Report
```sql theme={null}
-- Upcoming birthdays and anniversaries for pastoral care
WITH upcoming_dates AS (
SELECT
person_id,
first_name,
last_name,
birthdate,
anniversary,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) + 1 as turning_age,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, anniversary)) + 1 as anniversary_years,
-- Calculate next birthday
CASE
WHEN DATE_PART('doy', birthdate) >= DATE_PART('doy', CURRENT_DATE)
THEN DATE_TRUNC('year', CURRENT_DATE) + (birthdate - DATE_TRUNC('year', birthdate))
ELSE DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 year' + (birthdate - DATE_TRUNC('year', birthdate))
END as next_birthday,
-- Calculate next anniversary
CASE
WHEN anniversary IS NOT NULL AND DATE_PART('doy', anniversary) >= DATE_PART('doy', CURRENT_DATE)
THEN DATE_TRUNC('year', CURRENT_DATE) + (anniversary - DATE_TRUNC('year', anniversary))
ELSE DATE_TRUNC('year', CURRENT_DATE) + INTERVAL '1 year' + (anniversary - DATE_TRUNC('year', anniversary))
END as next_anniversary
FROM planning_center.people_people
WHERE status = 'active'
AND (birthdate IS NOT NULL OR anniversary IS NOT NULL)
),
combined_events AS (
SELECT
person_id,
first_name,
last_name,
'Birthday' as event_type,
next_birthday as event_date,
turning_age::TEXT || ' years old' as detail
FROM upcoming_dates
WHERE birthdate IS NOT NULL
UNION ALL
SELECT
person_id,
first_name,
last_name,
'Anniversary' as event_type,
next_anniversary as event_date,
anniversary_years::TEXT || ' years' as detail
FROM upcoming_dates
WHERE anniversary IS NOT NULL
)
SELECT
TO_CHAR(event_date, 'MM/DD') as date,
TO_CHAR(event_date, 'FMDay') as day_of_week,
first_name || ' ' || last_name as person,
event_type,
detail,
event_date - CURRENT_DATE as days_until
FROM combined_events
WHERE event_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days'
ORDER BY event_date, last_name;
```
### Follow-Up Required Report
```sql theme={null}
-- People requiring pastoral follow-up based on various criteria
WITH follow_up_needs AS (
-- Recently inactive
SELECT
p.person_id,
p.first_name || ' ' || p.last_name as name,
'Recently Inactive' as reason,
p.inactivated_at as trigger_date,
ir.value as details,
1 as priority
FROM planning_center.people_people p
LEFT JOIN planning_center.people_people_relationships pr
ON p.person_id = pr.person_id
AND pr.relationship_type = 'InactiveReason'
LEFT JOIN planning_center.people_inactive_reasons ir
ON pr.relationship_id = ir.inactive_reason_id
WHERE p.status = 'inactive'
AND p.inactivated_at >= CURRENT_DATE - INTERVAL '30 days'
UNION ALL
-- First-time visitors (no membership status)
SELECT
person_id,
first_name || ' ' || last_name,
'First-Time Visitor',
created_at,
'Joined ' || TO_CHAR(created_at, 'MM/DD'),
2
FROM planning_center.people_people
WHERE membership = 'Visitor'
AND created_at >= CURRENT_DATE - INTERVAL '14 days'
AND status = 'active'
UNION ALL
-- Background check expiring
SELECT
p.person_id,
p.first_name || ' ' || p.last_name,
'Background Check Expiring',
bc.expires_on,
'Expires ' || TO_CHAR(bc.expires_on, 'MM/DD'),
3
FROM planning_center.people_people p
JOIN planning_center.people_background_checks_relationships bcr
ON bcr.relationship_type = 'Person' AND bcr.relationship_id = p.person_id
JOIN planning_center.people_background_checks bc
ON bc.background_check_id = bcr.background_check_id
WHERE bc.expires_on BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days'
AND p.status = 'active'
)
SELECT
name,
reason,
details,
TO_CHAR(trigger_date, 'MM/DD/YYYY') as date,
CURRENT_DATE - trigger_date::DATE as days_ago,
CASE priority
WHEN 1 THEN '🔴 High'
WHEN 2 THEN '🟡 Medium'
ELSE '🟢 Low'
END as priority_level
FROM follow_up_needs
ORDER BY priority, trigger_date DESC;
```
## Household Analysis Reports
### Family Composition Report
```sql theme={null}
-- Analyze household structures and family compositions
WITH household_analysis AS (
SELECT
h.household_id,
h.name as household_name,
h.member_count,
COUNT(DISTINCT CASE WHEN p.child = true THEN p.person_id END) as children,
COUNT(DISTINCT CASE WHEN p.child = false OR p.child IS NULL THEN p.person_id END) as adults,
COUNT(DISTINCT CASE WHEN p.graduation_year IS NOT NULL THEN p.person_id END) as students,
MIN(p.birthdate) as oldest_member_birthdate,
MAX(p.birthdate) as youngest_member_birthdate,
STRING_AGG(p.first_name, ', ' ORDER BY p.birthdate) as members
FROM planning_center.people_households h
JOIN planning_center.people_households_relationships hhr
ON hhr.household_id = h.household_id AND LOWER(hhr.relationship_type) = 'person'
JOIN planning_center.people_people p
ON p.person_id = hhr.relationship_id
AND p.status = 'active'
GROUP BY h.household_id, h.name, h.member_count
),
household_categories AS (
SELECT
household_name,
member_count,
adults,
children,
students,
members,
CASE
WHEN adults = 1 AND children = 0 THEN 'Single Adult'
WHEN adults = 2 AND children = 0 THEN 'Married No Children'
WHEN adults = 1 AND children > 0 THEN 'Single Parent'
WHEN adults = 2 AND children > 0 THEN 'Nuclear Family'
WHEN adults > 2 THEN 'Multi-Generational'
ELSE 'Other'
END as family_type,
CASE
WHEN children > 0 AND children <= 2 THEN 'Small Family'
WHEN children >= 3 THEN 'Large Family'
WHEN students > 0 THEN 'Family with Teens'
ELSE 'No Children'
END as family_stage
FROM household_analysis
)
SELECT
family_type,
COUNT(*) as household_count,
SUM(member_count) as total_people,
ROUND(AVG(member_count), 1) as avg_household_size,
SUM(children) as total_children,
SUM(adults) as total_adults,
ROUND(AVG(children), 1) as avg_children_per_household,
ROUND((COUNT(*)::NUMERIC / (SELECT COUNT(*) FROM household_categories)) * 100, 1) as percent_of_households
FROM household_categories
GROUP BY family_type
ORDER BY household_count DESC;
```
## Campus Analysis Reports
### Multi-Campus Distribution Report
```sql theme={null}
-- Analyze member distribution across campuses
WITH campus_metrics AS (
SELECT
c.campus_id,
c.name as campus_name,
c.city,
c.state,
COUNT(DISTINCT pr.person_id) as total_members,
COUNT(DISTINCT CASE WHEN p.membership = 'Member' THEN pr.person_id END) as full_members,
COUNT(DISTINCT CASE WHEN p.child = true THEN pr.person_id END) as children,
COUNT(DISTINCT CASE WHEN p.graduation_year IS NOT NULL THEN pr.person_id END) as students,
COUNT(DISTINCT CASE WHEN p.created_at >= CURRENT_DATE - INTERVAL '90 days' THEN pr.person_id END) as new_in_90_days,
AVG(EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.birthdate))) as avg_age
FROM planning_center.people_campuses c
LEFT JOIN planning_center.people_people_relationships pr
ON c.campus_id = pr.relationship_id
AND pr.relationship_type = 'PrimaryCampus'
LEFT JOIN planning_center.people_people p
ON pr.person_id = p.person_id
AND p.status = 'active'
GROUP BY c.campus_id, c.name, c.city, c.state
),
campus_growth AS (
SELECT
c.name as campus_name,
COUNT(DISTINCT CASE
WHEN p.created_at >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
THEN pr.person_id
END) as last_month,
COUNT(DISTINCT CASE
WHEN p.created_at >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '2 months'
AND p.created_at < DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
THEN pr.person_id
END) as two_months_ago
FROM planning_center.people_campuses c
LEFT JOIN planning_center.people_people_relationships pr
ON c.campus_id = pr.relationship_id
AND pr.relationship_type = 'PrimaryCampus'
LEFT JOIN planning_center.people_people p
ON pr.person_id = p.person_id
GROUP BY c.name
)
SELECT
cm.campus_name,
cm.city || ', ' || cm.state as location,
cm.total_members,
cm.full_members,
ROUND((cm.full_members::NUMERIC / NULLIF(cm.total_members, 0)) * 100, 1) as member_percentage,
cm.children,
cm.students,
ROUND(cm.avg_age, 0) as avg_age,
cm.new_in_90_days as new_members_90d,
cg.last_month - cg.two_months_ago as monthly_growth,
ROUND((cm.total_members::NUMERIC / (SELECT SUM(total_members) FROM campus_metrics)) * 100, 1) as percent_of_church
FROM campus_metrics cm
JOIN campus_growth cg ON cm.campus_name = cg.campus_name
WHERE cm.total_members > 0
ORDER BY cm.total_members DESC;
```
## Communication Reports
### Contact Information Completeness Report
```sql theme={null}
-- Analyze completeness of contact information for communication planning
WITH contact_completeness AS (
SELECT
p.person_id,
p.first_name,
p.last_name,
p.status,
p.membership,
CASE WHEN e.email_id IS NOT NULL THEN 1 ELSE 0 END as has_email,
CASE WHEN ph.phone_number_id IS NOT NULL THEN 1 ELSE 0 END as has_phone,
CASE WHEN a.address_id IS NOT NULL THEN 1 ELSE 0 END as has_address,
CASE WHEN e.blocked = true THEN 1 ELSE 0 END as email_blocked
FROM planning_center.people_people p
LEFT JOIN planning_center.people_people_relationships er
ON p.person_id = er.person_id AND er.relationship_type = 'Email'
LEFT JOIN planning_center.people_emails e
ON e.email_id = er.relationship_id
AND e.is_primary = true
LEFT JOIN planning_center.people_people_relationships pnr
ON p.person_id = pnr.person_id AND pnr.relationship_type = 'PhoneNumber'
LEFT JOIN planning_center.people_phone_numbers ph
ON ph.phone_number_id = pnr.relationship_id
AND ph.is_primary = true
LEFT JOIN planning_center.people_people_relationships ar
ON p.person_id = ar.person_id AND ar.relationship_type = 'Address'
LEFT JOIN planning_center.people_addresses a
ON a.address_id = ar.relationship_id
AND a.is_primary = true
WHERE p.status = 'active'
),
completeness_summary AS (
SELECT
membership,
COUNT(*) as total_people,
SUM(has_email) as with_email,
SUM(has_phone) as with_phone,
SUM(has_address) as with_address,
SUM(CASE WHEN has_email = 1 AND has_phone = 1 THEN 1 ELSE 0 END) as email_and_phone,
SUM(CASE WHEN has_email = 1 AND has_phone = 1 AND has_address = 1 THEN 1 ELSE 0 END) as complete_contact,
SUM(CASE WHEN has_email = 0 AND has_phone = 0 THEN 1 ELSE 0 END) as no_contact,
SUM(email_blocked) as emails_blocked
FROM contact_completeness
GROUP BY membership
)
SELECT
COALESCE(membership, 'Unknown') as membership_level,
total_people,
with_email,
ROUND((with_email::NUMERIC / total_people) * 100, 1) as email_percentage,
with_phone,
ROUND((with_phone::NUMERIC / total_people) * 100, 1) as phone_percentage,
with_address,
ROUND((with_address::NUMERIC / total_people) * 100, 1) as address_percentage,
complete_contact,
ROUND((complete_contact::NUMERIC / total_people) * 100, 1) as complete_percentage,
no_contact,
emails_blocked
FROM completeness_summary
ORDER BY
CASE membership
WHEN 'Member' THEN 1
WHEN 'Regular Attender' THEN 2
WHEN 'Visitor' THEN 3
ELSE 4
END;
```
## Growth and Retention Reports
### Quarterly Growth Analysis
```sql theme={null}
-- Track membership growth patterns over quarters
WITH quarterly_data AS (
SELECT
DATE_TRUNC('quarter', created_at) as quarter,
COUNT(DISTINCT person_id) as new_people,
COUNT(DISTINCT CASE WHEN membership = 'Member' THEN person_id END) as new_members,
COUNT(DISTINCT CASE WHEN membership = 'Regular Attender' THEN person_id END) as new_regular,
COUNT(DISTINCT CASE WHEN membership = 'Visitor' THEN person_id END) as new_visitors,
COUNT(DISTINCT CASE WHEN child = true THEN person_id END) as new_children
FROM planning_center.people_people
WHERE created_at >= DATE_TRUNC('quarter', CURRENT_DATE) - INTERVAL '1 year'
AND status = 'active'
GROUP BY DATE_TRUNC('quarter', created_at)
),
inactivated_data AS (
SELECT
DATE_TRUNC('quarter', inactivated_at) as quarter,
COUNT(DISTINCT person_id) as people_inactivated
FROM planning_center.people_people
WHERE inactivated_at >= DATE_TRUNC('quarter', CURRENT_DATE) - INTERVAL '1 year'
GROUP BY DATE_TRUNC('quarter', inactivated_at)
),
combined_metrics AS (
SELECT
qd.quarter,
TO_CHAR(qd.quarter, 'Q[Q] YYYY') as quarter_label,
qd.new_people,
qd.new_members,
qd.new_regular,
qd.new_visitors,
qd.new_children,
COALESCE(id.people_inactivated, 0) as people_lost,
qd.new_people - COALESCE(id.people_inactivated, 0) as net_growth,
LAG(qd.new_people, 1) OVER (ORDER BY qd.quarter) as prev_quarter_new
FROM quarterly_data qd
LEFT JOIN inactivated_data id ON qd.quarter = id.quarter
)
SELECT
quarter_label,
new_people,
new_members,
new_regular,
new_visitors,
people_lost,
net_growth,
CASE
WHEN prev_quarter_new > 0 THEN
ROUND(((new_people - prev_quarter_new)::NUMERIC / prev_quarter_new) * 100, 1)
ELSE NULL
END as growth_rate,
ROUND((new_members::NUMERIC / NULLIF(new_people, 0)) * 100, 1) as member_conversion_rate
FROM combined_metrics
ORDER BY quarter DESC;
```
## Year-End Summary Report
### Annual Church Census Report
```sql theme={null}
-- Comprehensive year-end census for annual reports
WITH yearly_stats AS (
SELECT
-- Total counts
COUNT(DISTINCT CASE WHEN status = 'active' THEN person_id END) as total_active,
COUNT(DISTINCT CASE WHEN status = 'inactive' THEN person_id END) as total_inactive,
COUNT(DISTINCT CASE WHEN status = 'active' AND membership = 'Member' THEN person_id END) as members,
COUNT(DISTINCT CASE WHEN status = 'active' AND membership = 'Regular Attender' THEN person_id END) as regular_attenders,
COUNT(DISTINCT CASE WHEN status = 'active' AND membership = 'Visitor' THEN person_id END) as visitors,
-- Demographics
COUNT(DISTINCT CASE WHEN status = 'active' AND child = true THEN person_id END) as children,
COUNT(DISTINCT CASE WHEN status = 'active' AND graduation_year IS NOT NULL THEN person_id END) as students,
COUNT(DISTINCT CASE WHEN status = 'active' AND EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate)) >= 65 THEN person_id END) as seniors,
-- New this year
COUNT(DISTINCT CASE
WHEN created_at >= DATE_TRUNC('year', CURRENT_DATE)
AND status = 'active'
THEN person_id
END) as new_this_year,
-- Lost this year
COUNT(DISTINCT CASE
WHEN inactivated_at >= DATE_TRUNC('year', CURRENT_DATE)
THEN person_id
END) as lost_this_year,
-- Gender breakdown
COUNT(DISTINCT CASE WHEN status = 'active' AND gender = 'Male' THEN person_id END) as males,
COUNT(DISTINCT CASE WHEN status = 'active' AND gender = 'Female' THEN person_id END) as females
FROM planning_center.people_people
),
household_yearly AS (
SELECT
COUNT(DISTINCT household_id) as total_households,
AVG(member_count) as avg_household_size,
COUNT(DISTINCT CASE WHEN member_count = 1 THEN household_id END) as single_households,
COUNT(DISTINCT CASE WHEN member_count >= 4 THEN household_id END) as large_households
FROM planning_center.people_households
),
campus_count AS (
SELECT COUNT(DISTINCT campus_id) as campus_count
FROM planning_center.people_campuses
)
SELECT
'===========================================' as divider1,
TO_CHAR(DATE_TRUNC('year', CURRENT_DATE), 'YYYY') || ' ANNUAL CHURCH CENSUS REPORT' as report_title,
'===========================================' as divider2,
'' as blank1,
'📊 MEMBERSHIP OVERVIEW' as section1,
'-------------------------------------------' as divider3,
ys.total_active as total_active_people,
ys.members as full_members,
ys.regular_attenders,
ys.visitors,
ys.new_this_year as people_added_this_year,
ys.lost_this_year as people_lost_this_year,
ys.new_this_year - ys.lost_this_year as net_growth,
'' as blank2,
'👥 DEMOGRAPHICS' as section2,
'-------------------------------------------' as divider4,
ys.children,
ys.students,
ys.total_active - ys.children - ys.students - ys.seniors as working_adults,
ys.seniors,
ys.males,
ys.females,
ROUND(ys.males::NUMERIC / NULLIF(ys.males + ys.females, 0) * 100, 0) as male_percentage,
'' as blank3,
'🏠 HOUSEHOLD STATISTICS' as section3,
'-------------------------------------------' as divider5,
hy.total_households,
ROUND(hy.avg_household_size, 1) as avg_household_size,
hy.single_households as single_person_households,
hy.large_households as households_4_plus,
'' as blank4,
'📍 ORGANIZATIONAL' as section4,
'-------------------------------------------' as divider6,
cc.campus_count as number_of_campuses,
ROUND(ys.total_active::NUMERIC / cc.campus_count, 0) as avg_per_campus,
'' as blank5,
'===========================================' as divider7
FROM yearly_stats ys, household_yearly hy, campus_count cc;
```
## Export Tips
These reports can be exported in various formats:
1. **CSV Export**: Add `\copy (SELECT ...) TO 'report.csv' CSV HEADER;`
2. **Excel-Ready**: Most results can be copied directly into Excel
3. **Automated Delivery**: Schedule these queries to run weekly/monthly
4. **Dashboard Integration**: Use these queries as data sources for BI tools
## Next Steps
* Review the [Data Model](/planning-center/people/data-model) for complete field documentation
* Check [Advanced Queries](/planning-center/people/advanced-queries) for more complex analysis techniques
* Return to [Basic Queries](/planning-center/people/basic-queries) for simpler examples
* Visit [Overview](/planning-center/people/overview) to understand the People system
# Advanced Planning Center Publishing Queries
Source: https://docs.getparable.io/planning-center/publishing/advanced-queries
Advanced Publishing SQL for cross-module analysis: connect sermon series to attendance and giving, and measure content reach across channels.
Complex queries for deep analysis, cross-module insights, and sophisticated reporting. These queries demonstrate advanced SQL techniques and integration patterns.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Publishing module live in the `planning_center` schema. Always prefix table names with `planning_center.` when writing advanced queries.
✅ CORRECT: `SELECT * FROM planning_center.publishing_episodes`
❌ INCORRECT: `SELECT * FROM publishing_episodes`
### Row Level Security (RLS)
Row Level Security automatically governs:
* **tenant\_organization\_id** – restricts results to your organization
* **system\_status** – active records returned by default
**Do not add these filters manually**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus on media analytics, engagement, and cross-module joins while trusting RLS for tenancy and system status.
## Time-Based Analytics
### Engagement Decay Analysis
How quickly do views drop off after publishing?
```sql theme={null}
-- Analyze watch count by episode for recent content
-- Note: episode_statistics is a point-in-time snapshot (no created_at for decay analysis)
WITH episode_watch_metrics AS (
SELECT
e.episode_id,
e.title,
e.published_live_at,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as total_watches,
est.library_watch_count,
est.live_watch_count,
EXTRACT(DAY FROM (CURRENT_DATE - e.published_live_at)) as days_since_publish,
CASE
WHEN EXTRACT(DAY FROM (CURRENT_DATE - e.published_live_at)) <= 7 THEN '0-1 weeks'
WHEN EXTRACT(DAY FROM (CURRENT_DATE - e.published_live_at)) <= 14 THEN '1-2 weeks'
WHEN EXTRACT(DAY FROM (CURRENT_DATE - e.published_live_at)) <= 28 THEN '2-4 weeks'
ELSE '4+ weeks'
END as age_bucket
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '6 months'
AND e.published_live_at IS NOT NULL
)
SELECT
age_bucket,
COUNT(DISTINCT episode_id) as episodes_measured,
AVG(total_watches) as avg_total_watches,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_watches) as median_watches,
SUM(total_watches) as total_watches
FROM episode_watch_metrics
GROUP BY age_bucket
ORDER BY age_bucket;
```
### Year-over-Year Growth Analysis
Compare publishing metrics across years.
```sql theme={null}
-- Year-over-year publishing comparison
WITH yearly_metrics AS (
SELECT
DATE_PART('year', e.published_live_at) as year,
DATE_PART('month', e.published_live_at) as month,
COUNT(DISTINCT e.episode_id) as episodes_published,
COUNT(DISTINCT ser_er.relationship_id) as active_series,
COUNT(DISTINCT spr.relationship_id) as unique_speakers,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
WHERE e.published_live_at >= DATE_TRUNC('year', CURRENT_DATE - INTERVAL '2 years')
GROUP BY year, month
)
SELECT
ym.month,
ym.episodes_published as current_year_episodes,
ym_prev.episodes_published as last_year_episodes,
ROUND(((ym.episodes_published::numeric - ym_prev.episodes_published) /
NULLIF(ym_prev.episodes_published, 0)) * 100, 2) as episode_growth_pct,
ym.total_watches as current_year_watches,
ym_prev.total_watches as last_year_watches,
ROUND(((ym.total_watches::numeric - ym_prev.total_watches) /
NULLIF(ym_prev.total_watches, 0)) * 100, 2) as watch_growth_pct,
ym.avg_watches_per_episode as current_avg_watches,
ym_prev.avg_watches_per_episode as last_year_avg_watches
FROM yearly_metrics ym
LEFT JOIN yearly_metrics ym_prev
ON ym.month = ym_prev.month
AND ym.year = ym_prev.year + 1
WHERE ym.year = DATE_PART('year', CURRENT_DATE)
ORDER BY ym.month;
```
### Publishing Consistency Score
Measure how consistently you publish content.
```sql theme={null}
-- Publishing consistency analysis
WITH weekly_publishing AS (
SELECT
DATE_TRUNC('week', published_live_at) as week,
COUNT(*) as episodes_published,
ARRAY_AGG(DISTINCT EXTRACT(DOW FROM published_live_at)) as publishing_days,
ARRAY_AGG(title ORDER BY published_live_at) as episode_titles
FROM planning_center.publishing_episodes
WHERE published_live_at >= CURRENT_DATE - INTERVAL '52 weeks'
AND published_live_at IS NOT NULL
GROUP BY week
),
consistency_metrics AS (
SELECT
COUNT(*) as total_weeks,
COUNT(CASE WHEN episodes_published > 0 THEN 1 END) as weeks_with_content,
AVG(episodes_published) as avg_episodes_per_week,
STDDEV(episodes_published) as stddev_episodes,
MODE() WITHIN GROUP (ORDER BY episodes_published) as mode_episodes_per_week,
MAX(episodes_published) as max_episodes_in_week,
MIN(CASE WHEN episodes_published > 0 THEN episodes_published END) as min_episodes_in_week
FROM weekly_publishing
)
SELECT
total_weeks,
weeks_with_content,
ROUND((weeks_with_content::numeric / total_weeks) * 100, 2) as consistency_percentage,
ROUND(avg_episodes_per_week::numeric, 2) as avg_episodes_per_week,
ROUND(stddev_episodes::numeric, 2) as publishing_variance,
mode_episodes_per_week as typical_weekly_episodes,
max_episodes_in_week,
min_episodes_in_week,
CASE
WHEN (weeks_with_content::numeric / total_weeks) >= 0.95 THEN 'Excellent'
WHEN (weeks_with_content::numeric / total_weeks) >= 0.85 THEN 'Good'
WHEN (weeks_with_content::numeric / total_weeks) >= 0.70 THEN 'Fair'
ELSE 'Needs Improvement'
END as consistency_rating
FROM consistency_metrics;
```
## Speaker Analytics
### Speaker Collaboration Patterns
Which speakers frequently teach together?
```sql theme={null}
-- Find speaker collaboration patterns
WITH episode_speakers AS (
SELECT
e.episode_id,
e.title as episode_title,
e.published_live_at,
ARRAY_AGG(sp.formatted_name ORDER BY sp.formatted_name) as speakers,
ARRAY_AGG(sp.speaker_id ORDER BY sp.speaker_id) as speaker_ids,
COUNT(sp.speaker_id) as speaker_count
FROM planning_center.publishing_episodes e
JOIN planning_center.publishing_episodes_relationships er_ship
ON er_ship.episode_id = e.episode_id AND er_ship.relationship_type = 'speakerships'
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = er_ship.relationship_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_speakers sp ON sp.speaker_id = spr.relationship_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '1 year'
GROUP BY e.episode_id, e.title, e.published_live_at
HAVING COUNT(sp.speaker_id) > 1
),
speaker_pairs AS (
SELECT
s1.speaker_id as speaker1_id,
s1.formatted_name as speaker1_name,
s2.speaker_id as speaker2_id,
s2.formatted_name as speaker2_name,
COUNT(DISTINCT e.episode_id) as episodes_together
FROM planning_center.publishing_episodes e
JOIN planning_center.publishing_episodes_relationships er_ship1
ON er_ship1.episode_id = e.episode_id AND er_ship1.relationship_type = 'speakerships'
JOIN planning_center.publishing_speakerships_relationships spr1
ON spr1.speakership_id = er_ship1.relationship_id AND spr1.relationship_type = 'speaker'
JOIN planning_center.publishing_speakers s1 ON s1.speaker_id = spr1.relationship_id
JOIN planning_center.publishing_episodes_relationships er_ship2
ON er_ship2.episode_id = e.episode_id AND er_ship2.relationship_type = 'speakerships'
JOIN planning_center.publishing_speakerships_relationships spr2
ON spr2.speakership_id = er_ship2.relationship_id AND spr2.relationship_type = 'speaker'
JOIN planning_center.publishing_speakers s2 ON s2.speaker_id = spr2.relationship_id
WHERE s1.speaker_id < s2.speaker_id -- Avoid duplicates
AND e.published_live_at >= CURRENT_DATE - INTERVAL '1 year'
GROUP BY s1.speaker_id, s1.formatted_name, s2.speaker_id, s2.formatted_name
)
SELECT
speaker1_name,
speaker2_name,
episodes_together,
ROUND(episodes_together::numeric / 52 * 100, 1) as pct_of_year_together
FROM speaker_pairs
ORDER BY episodes_together DESC;
```
### Speaker Topic Analysis
What topics does each speaker cover? (Based on series)
```sql theme={null}
-- Analyze speaker topics through series
WITH speaker_series_stats AS (
SELECT
sp.speaker_id,
sp.formatted_name as speaker_name,
s.series_id,
s.title as series_title,
s.description as series_description,
COUNT(DISTINCT e.episode_id) as episodes_in_series,
MIN(e.published_live_at) as first_episode,
MAX(e.published_live_at) as last_episode,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_episodes_relationships er_ship
ON er_ship.relationship_id = spr.speakership_id AND er_ship.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = er_ship.episode_id
JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
JOIN planning_center.publishing_series s ON s.series_id = er_series.relationship_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
GROUP BY sp.speaker_id, sp.formatted_name, s.series_id, s.title, s.description
)
SELECT
speaker_name,
COUNT(DISTINCT series_id) as series_count,
SUM(episodes_in_series) as total_episodes,
ARRAY_AGG(series_title ORDER BY total_watches DESC) as series_taught,
ROUND(AVG(avg_watches)::numeric, 0) as avg_watches_per_episode,
SUM(total_watches) as total_career_watches,
MIN(first_episode) as teaching_since,
MAX(last_episode) as most_recent_teaching
FROM speaker_series_stats
GROUP BY speaker_id, speaker_name
ORDER BY total_episodes DESC;
```
### Speaker Performance Benchmarking
Compare speaker engagement metrics.
```sql theme={null}
-- Speaker performance benchmarking
WITH speaker_metrics AS (
SELECT
sp.speaker_id,
sp.formatted_name as speaker_name,
COUNT(DISTINCT e.episode_id) as episode_count,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
STDDEV(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as watch_stddev,
MAX(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as max_watches,
MIN(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as min_watches
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_episodes_relationships er_ship
ON er_ship.relationship_id = spr.speakership_id AND er_ship.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = er_ship.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '1 year'
GROUP BY sp.speaker_id, sp.formatted_name
HAVING COUNT(DISTINCT e.episode_id) >= 3 -- Minimum episodes for comparison
),
benchmarks AS (
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY avg_watches) as median_watches,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY avg_watches) as q3_watches,
AVG(avg_watches) as overall_avg_watches
FROM speaker_metrics
)
SELECT
sm.speaker_name,
sm.episode_count,
ROUND(sm.avg_watches::numeric, 0) as avg_watches,
ROUND(sm.watch_stddev::numeric, 0) as watch_consistency,
CASE
WHEN sm.avg_watches > b.q3_watches THEN 'Top Performer'
WHEN sm.avg_watches > b.median_watches THEN 'Above Average'
ELSE 'Below Average'
END as performance_tier,
ROUND(((sm.avg_watches - b.overall_avg_watches) / NULLIF(b.overall_avg_watches, 0)) * 100, 1) as pct_vs_average
FROM speaker_metrics sm
CROSS JOIN benchmarks b
ORDER BY sm.avg_watches DESC;
```
## Series Deep Dive
### Series Performance Trajectory
How do views change throughout a series?
```sql theme={null}
-- Analyze watch trajectory within series
WITH series_episodes AS (
SELECT
s.series_id,
s.title as series_title,
e.episode_id,
e.title as episode_title,
e.published_live_at,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as watch_count,
ROW_NUMBER() OVER (PARTITION BY s.series_id ORDER BY e.published_live_at) as episode_number,
COUNT(*) OVER (PARTITION BY s.series_id) as total_episodes_in_series,
FIRST_VALUE(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0))
OVER (PARTITION BY s.series_id ORDER BY e.published_live_at) as first_episode_watches,
LAST_VALUE(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0))
OVER (PARTITION BY s.series_id ORDER BY e.published_live_at
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as last_episode_watches
FROM planning_center.publishing_series s
JOIN planning_center.publishing_episodes_relationships er
ON er.relationship_id = s.series_id AND er.relationship_type = 'series'
JOIN planning_center.publishing_episodes e ON e.episode_id = er.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE s.episodes_count >= 4 -- Series with at least 4 episodes
AND e.published_live_at IS NOT NULL
)
SELECT
series_title,
total_episodes_in_series,
first_episode_watches,
last_episode_watches,
ROUND(AVG(CASE WHEN episode_number = 1 THEN watch_count END)::numeric, 0) as ep1_avg_watches,
ROUND(AVG(CASE WHEN episode_number = 2 THEN watch_count END)::numeric, 0) as ep2_avg_watches,
ROUND(AVG(CASE WHEN episode_number = 3 THEN watch_count END)::numeric, 0) as ep3_avg_watches,
ROUND(AVG(CASE WHEN episode_number = total_episodes_in_series THEN watch_count END)::numeric, 0) as final_ep_avg_watches,
ROUND(((last_episode_watches::numeric - first_episode_watches) /
NULLIF(first_episode_watches, 0)) * 100, 1) as watch_change_pct,
CASE
WHEN last_episode_watches > first_episode_watches * 1.1 THEN 'Growing Engagement'
WHEN last_episode_watches < first_episode_watches * 0.9 THEN 'Declining Engagement'
ELSE 'Stable Engagement'
END as engagement_trend
FROM series_episodes
GROUP BY series_id, series_title, total_episodes_in_series, first_episode_watches, last_episode_watches
ORDER BY total_episodes_in_series DESC, series_title;
```
### Optimal Series Length Analysis
What's the ideal number of episodes for a series?
```sql theme={null}
-- Analyze engagement by series length
WITH series_performance AS (
SELECT
s.series_id,
s.title,
s.episodes_count,
COUNT(DISTINCT e.episode_id) as actual_episodes,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_series_watches,
STDDEV(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as watch_variance,
EXTRACT(DAY FROM (s.ended_at - s.started_at)) as series_duration_days
FROM planning_center.publishing_series s
JOIN planning_center.publishing_episodes_relationships er
ON er.relationship_id = s.series_id AND er.relationship_type = 'series'
JOIN planning_center.publishing_episodes e ON e.episode_id = er.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE s.published = true
AND s.ended_at IS NOT NULL
GROUP BY s.series_id, s.title, s.episodes_count, s.started_at, s.ended_at
),
length_buckets AS (
SELECT
CASE
WHEN episodes_count <= 2 THEN '1-2 Episodes'
WHEN episodes_count <= 4 THEN '3-4 Episodes'
WHEN episodes_count <= 6 THEN '5-6 Episodes'
WHEN episodes_count <= 8 THEN '7-8 Episodes'
ELSE '9+ Episodes'
END as series_length_bucket,
episodes_count,
COUNT(*) as series_count,
AVG(avg_watches_per_episode) as avg_watches,
AVG(total_series_watches) as avg_total_watches,
AVG(watch_variance) as avg_watch_variance,
AVG(series_duration_days) as avg_duration_days
FROM series_performance
GROUP BY series_length_bucket, episodes_count
)
SELECT
series_length_bucket,
series_count,
ROUND(avg_watches::numeric, 0) as avg_watches_per_episode,
ROUND(avg_total_watches::numeric, 0) as avg_total_series_watches,
ROUND(avg_watch_variance::numeric, 0) as watch_consistency_score,
ROUND(avg_duration_days::numeric, 0) as avg_series_days,
RANK() OVER (ORDER BY avg_watches DESC) as performance_rank
FROM length_buckets
ORDER BY series_length_bucket;
```
## Resource and Distribution Analysis
### Multi-Channel Performance Comparison
How does content perform across different channels?
```sql theme={null}
-- Compare performance across distribution channels
WITH channel_episodes AS (
SELECT
c.channel_id,
c.name as channel_name,
e.episode_id,
e.title as episode_title,
e.published_live_at,
et.starts_at as channel_publish_time,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as total_watches,
est.library_watch_count,
est.live_watch_count
FROM planning_center.publishing_channels c
JOIN planning_center.publishing_episodes_relationships er_ch
ON er_ch.relationship_id = c.channel_id AND er_ch.relationship_type = 'channel'
JOIN planning_center.publishing_episodes e ON e.episode_id = er_ch.episode_id
JOIN planning_center.publishing_episodes_relationships er_et
ON er_et.episode_id = e.episode_id AND er_et.relationship_type = 'episode_times'
JOIN planning_center.publishing_episode_times et ON et.episode_time_id = er_et.relationship_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '6 months'
)
SELECT
channel_name,
COUNT(DISTINCT episode_id) as episodes_published,
AVG(total_watches) as avg_watches,
SUM(total_watches) as total_watches,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_watches) as median_watches,
MAX(total_watches) as best_performing_episode_watches,
MIN(total_watches) as worst_performing_episode_watches,
AVG(EXTRACT(HOUR FROM (channel_publish_time - published_live_at))) as avg_delay_hours
FROM channel_episodes
GROUP BY channel_id, channel_name
ORDER BY total_watches DESC;
```
### Resource Utilization Analysis
Which resources are most popular?
```sql theme={null}
-- Analyze resource types and associated episode watches
WITH resource_metrics AS (
SELECT
eresrc.kind as resource_type,
eresrc.title as resource_name,
eresrc.url,
e.title as episode_title,
s.title as series_title,
e.published_live_at,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as episode_watches,
CASE
WHEN eresrc.kind ILIKE '%note%' THEN 'Notes'
WHEN eresrc.kind ILIKE '%slide%' OR eresrc.kind ILIKE '%presentation%' THEN 'Slides'
WHEN eresrc.kind ILIKE '%guide%' OR eresrc.kind ILIKE '%study%' THEN 'Study Materials'
WHEN eresrc.kind ILIKE '%video%' THEN 'Video'
WHEN eresrc.kind ILIKE '%audio%' THEN 'Audio'
ELSE 'Other'
END as resource_category
FROM planning_center.publishing_episode_resources eresrc
JOIN planning_center.publishing_episodes_relationships er_res
ON er_res.relationship_id = eresrc.episode_resource_id AND er_res.relationship_type = 'episode_resources'
JOIN planning_center.publishing_episodes e ON e.episode_id = er_res.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s ON s.series_id = er_series.relationship_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '3 months'
)
SELECT
resource_category,
COUNT(*) as resource_count,
COUNT(DISTINCT episode_title) as episodes_with_resource,
AVG(episode_watches) as avg_episode_watches_with_resource,
ARRAY_AGG(DISTINCT resource_type) as resource_types
FROM resource_metrics
GROUP BY resource_category
ORDER BY resource_count DESC;
```
## Cross-Module Integration
### Publishing and Check-Ins Correlation
Compare online watches with in-person attendance week by week.
Planning Center Publishing reports watch counts in aggregate only — it does not
identify individual viewers. There is no viewer-to-person join, so online
audiences cannot be broken down by membership status or campus. Correlate the
two channels **by week** instead, as shown below.
```sql theme={null}
-- Online watches vs. in-person check-ins, week by week
WITH online AS (
SELECT
DATE_TRUNC('week', e.published_live_at) as week,
COUNT(DISTINCT e.episode_id) as episodes_published,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY DATE_TRUNC('week', e.published_live_at)
),
in_person AS (
SELECT
DATE_TRUNC('week', c.created_at) as week,
COUNT(DISTINCT c.check_in_id) as check_ins,
COUNT(DISTINCT cr.relationship_id) as unique_attendees
FROM planning_center.checkins_check_ins c
JOIN planning_center.checkins_check_ins_relationships cr
ON cr.check_in_id = c.check_in_id AND cr.relationship_type = 'Person'
WHERE c.created_at >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY DATE_TRUNC('week', c.created_at)
)
SELECT
COALESCE(o.week, i.week) as week,
COALESCE(o.episodes_published, 0) as episodes_published,
COALESCE(o.total_watches, 0) as total_watches,
COALESCE(i.check_ins, 0) as check_ins,
COALESCE(i.unique_attendees, 0) as unique_attendees,
ROUND(
COALESCE(o.total_watches, 0)::numeric
/ NULLIF(COALESCE(i.unique_attendees, 0), 0),
2
) as watches_per_attendee
FROM online o
FULL OUTER JOIN in_person i ON i.week = o.week
ORDER BY week DESC;
```
### Publishing and Giving Correlation
Analyze giving patterns during sermon series.
```sql theme={null}
-- Correlate sermon series with giving patterns (requires Giving module)
WITH series_giving_periods AS (
SELECT
s.series_id,
s.title as series_title,
s.started_at,
s.ended_at,
COUNT(DISTINCT e.episode_id) as episode_count,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_episode_watches
FROM planning_center.publishing_series s
JOIN planning_center.publishing_episodes_relationships er
ON er.relationship_id = s.series_id AND er.relationship_type = 'series'
JOIN planning_center.publishing_episodes e ON e.episode_id = er.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE s.started_at >= CURRENT_DATE - INTERVAL '1 year'
AND s.ended_at IS NOT NULL
GROUP BY s.series_id, s.title, s.started_at, s.ended_at
),
giving_during_series AS (
SELECT
sgp.series_id,
sgp.series_title,
sgp.started_at,
sgp.ended_at,
sgp.episode_count,
sgp.avg_episode_watches,
-- Join with giving data
COUNT(DISTINCT d.donation_id) as donations_during_series,
SUM(d.amount_cents) / 100.0 as total_giving_amount,
COUNT(DISTINCT dr.relationship_id) as unique_donors,
AVG(d.amount_cents) / 100.0 as avg_donation_amount
FROM series_giving_periods sgp
LEFT JOIN planning_center.giving_donations d
ON d.received_at BETWEEN sgp.started_at AND COALESCE(sgp.ended_at, CURRENT_DATE)
LEFT JOIN planning_center.giving_donations_relationships dr
ON dr.donation_id = d.donation_id AND dr.relationship_type = 'Person'
GROUP BY sgp.series_id, sgp.series_title, sgp.started_at, sgp.ended_at,
sgp.episode_count, sgp.avg_episode_watches
)
SELECT
series_title,
started_at,
ended_at,
episode_count,
ROUND(avg_episode_watches::numeric, 0) as avg_watches,
donations_during_series,
ROUND(total_giving_amount::numeric, 2) as total_giving,
unique_donors,
ROUND(avg_donation_amount::numeric, 2) as avg_donation,
ROUND((total_giving_amount / NULLIF(episode_count, 0))::numeric, 2) as giving_per_episode
FROM giving_during_series
ORDER BY started_at DESC;
```
## Performance Optimization Queries
### Identify Slow-Performing Content
Find content that needs promotion or improvement.
```sql theme={null}
-- Identify underperforming content for optimization
WITH performance_benchmarks AS (
SELECT
PERCENTILE_CONT(0.25) WITHIN GROUP (
ORDER BY COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)
) as q1_watches,
PERCENTILE_CONT(0.5) WITHIN GROUP (
ORDER BY COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)
) as median_watches,
PERCENTILE_CONT(0.75) WITHIN GROUP (
ORDER BY COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)
) as q3_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches
FROM planning_center.publishing_episodes e
JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '3 months'
),
episode_performance AS (
SELECT
e.episode_id,
e.title,
e.published_live_at,
s.title as series_title,
sp.formatted_name as speaker_name,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as watch_count,
resource_counts.resource_count,
EXTRACT(DAY FROM (CURRENT_DATE - e.published_live_at)) as days_since_published
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s ON s.series_id = er_series.relationship_id
LEFT JOIN planning_center.publishing_episodes_relationships er_ship
ON er_ship.episode_id = e.episode_id AND er_ship.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = er_ship.relationship_id AND spr.relationship_type = 'speaker'
LEFT JOIN planning_center.publishing_speakers sp ON sp.speaker_id = spr.relationship_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN (
SELECT er.episode_id, COUNT(*) as resource_count
FROM planning_center.publishing_episodes_relationships er
WHERE er.relationship_type = 'episode_resources'
GROUP BY er.episode_id
) resource_counts ON resource_counts.episode_id = e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '3 months'
)
SELECT
ep.title,
ep.series_title,
ep.speaker_name,
ep.published_live_at,
ep.days_since_published,
ep.watch_count,
pb.median_watches as expected_watches,
ROUND((((ep.watch_count::numeric - pb.median_watches) / NULLIF(pb.median_watches, 0)) * 100)::numeric, 1) as performance_vs_median_pct,
CASE
WHEN ep.watch_count < pb.q1_watches THEN 'Critical - Bottom 25%'
WHEN ep.watch_count < pb.median_watches THEN 'Below Average'
WHEN ep.watch_count < pb.q3_watches THEN 'Above Average'
ELSE 'Top Performer'
END as performance_tier,
COALESCE(ep.resource_count, 0) as resources_available,
CASE
WHEN ep.watch_count < pb.q1_watches AND ep.resource_count = 0 THEN 'Add Resources'
WHEN ep.watch_count < pb.q1_watches AND ep.days_since_published < 7 THEN 'Needs Promotion'
WHEN ep.watch_count < pb.q1_watches THEN 'Review Content Quality'
ELSE 'No Action Needed'
END as recommended_action
FROM episode_performance ep
CROSS JOIN performance_benchmarks pb
WHERE ep.watch_count < pb.median_watches
ORDER BY ep.watch_count ASC, ep.published_live_at DESC;
```
## Tips for Advanced Queries
1. **Use CTEs (WITH clauses)** for complex multi-step analysis
2. **Window Functions** for running totals and rankings
3. **PERCENTILE\_CONT** for statistical analysis
4. **ARRAY\_AGG** to collect related values
5. **CASE statements** for conditional logic and categorization
6. **Cross-module joins** require understanding your data relationships
## Performance Considerations
* Add indexes on frequently joined columns
* Use `EXPLAIN ANALYZE` to optimize slow queries
* Consider materialized views for complex reports
* Partition large tables by date if needed
## Next Steps
* [Reporting Examples](/planning-center/publishing/reporting-examples) - Production-ready report templates
* [Data Model](/planning-center/publishing/data-model) - Complete schema reference
* [Basic Queries](/planning-center/publishing/basic-queries) - Simpler query examples
***
*Advanced analysis leads to advanced insights. Use these patterns to unlock the full potential of your publishing data.*
# Basic Planning Center Publishing Queries
Source: https://docs.getparable.io/planning-center/publishing/basic-queries
Simple SQL for Planning Center Publishing: recent episodes, episodes in a series, series episode counts, speaker frequency, and totals by channel.
Simple, powerful queries to get started with your Publishing data. Copy, paste, and customize these for your needs!
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Publishing module live in the `planning_center` schema. Always prefix table names with `planning_center.` when querying.
✅ CORRECT: `SELECT * FROM planning_center.publishing_episodes`
❌ INCORRECT: `SELECT * FROM publishing_episodes`
### Row Level Security (RLS)
Row Level Security automatically governs:
* **tenant\_organization\_id** – restricts results to your organization
* **system\_status** – returns active records by default
**Do not add these filters manually**—RLS already applies them and redundant predicates can suppress data or hurt performance:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus on content-specific filters (series, published dates, media metadata) while relying on RLS for tenancy and status.
## Recent Episodes
### Latest Published Episodes
See your most recent content with key details.
```sql theme={null}
-- Get the 20 most recent published episodes
SELECT
episode_id,
title,
description,
published_live_at,
video_url,
church_center_url
FROM planning_center.publishing_episodes
WHERE published_live_at IS NOT NULL
ORDER BY published_live_at DESC
LIMIT 20;
```
### Episodes by Series
Find all episodes in a specific series.
```sql theme={null}
-- Get all episodes from a series (replace series name)
SELECT
e.title as episode_title,
e.published_live_at,
e.description,
s.title as series_title,
s.started_at as series_start,
s.ended_at as series_end
FROM planning_center.publishing_episodes e
JOIN planning_center.publishing_episodes_relationships er
ON er.episode_id = e.episode_id AND er.relationship_type = 'series'
JOIN planning_center.publishing_series s ON s.series_id = er.relationship_id
WHERE s.title LIKE '%Christmas%' -- Change series name here
ORDER BY e.published_live_at;
```
### Episodes This Year
All episodes published in the current year.
```sql theme={null}
-- Episodes published this year
SELECT
title,
published_live_at,
video_url,
DATE_PART('month', published_live_at) as month,
DATE_PART('week', published_live_at) as week
FROM planning_center.publishing_episodes
WHERE published_live_at >= DATE_TRUNC('year', CURRENT_DATE)
AND published_live_at IS NOT NULL
ORDER BY published_live_at DESC;
```
## Series Analytics
### Active Series List
All series with episode counts.
```sql theme={null}
-- List all series with episode counts
SELECT
s.series_id,
s.title,
s.description,
s.started_at,
s.ended_at,
s.episodes_count,
s.published,
COUNT(er.episode_id) as actual_episode_count
FROM planning_center.publishing_series s
LEFT JOIN planning_center.publishing_episodes_relationships er
ON er.relationship_id = s.series_id AND er.relationship_type = 'series'
GROUP BY s.series_id, s.title, s.description,
s.started_at, s.ended_at, s.episodes_count, s.published
ORDER BY s.started_at DESC;
```
### Current/Recent Series
Series that are currently active or recently ended.
```sql theme={null}
-- Current and recent series (last 6 months)
SELECT
title,
description,
started_at,
ended_at,
episodes_count,
CASE
WHEN ended_at IS NULL THEN 'Ongoing'
WHEN ended_at > CURRENT_DATE THEN 'Upcoming'
ELSE 'Completed'
END as status
FROM planning_center.publishing_series
WHERE started_at >= CURRENT_DATE - INTERVAL '6 months'
OR ended_at IS NULL
OR ended_at >= CURRENT_DATE - INTERVAL '1 month'
ORDER BY started_at DESC;
```
### Series Duration Analysis
How long do your series typically run?
```sql theme={null}
-- Average series length and episode count
SELECT
COUNT(*) as total_series,
AVG(episodes_count) as avg_episodes_per_series,
AVG(
CASE
WHEN ended_at IS NOT NULL
THEN EXTRACT(DAY FROM (ended_at - started_at))
ELSE NULL
END
) as avg_series_duration_days,
MAX(episodes_count) as max_episodes_in_series,
MIN(episodes_count) as min_episodes_in_series
FROM planning_center.publishing_series
WHERE published = true
AND started_at IS NOT NULL;
```
## Speaker Insights
### Speaker Frequency
How often does each speaker teach?
```sql theme={null}
-- Speaker frequency in the last year
SELECT
sp.speaker_id,
sp.formatted_name as speaker_name,
COUNT(DISTINCT e.episode_id) as episode_count,
MIN(e.published_live_at) as first_episode,
MAX(e.published_live_at) as latest_episode,
COUNT(DISTINCT ser_er.relationship_id) as series_count
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_speakerships ship ON ship.speakership_id = spr.speakership_id
JOIN planning_center.publishing_episodes_relationships er_ship
ON er_ship.relationship_id = ship.speakership_id AND er_ship.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = er_ship.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '1 year'
GROUP BY sp.speaker_id, sp.formatted_name
ORDER BY episode_count DESC;
```
### Recent Speakers
Who has spoken in the last few months?
```sql theme={null}
-- Speakers in the last 3 months
SELECT DISTINCT
sp.formatted_name as speaker_name,
COUNT(e.episode_id) as recent_episodes,
MAX(e.published_live_at) as most_recent_episode
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_speakerships ship ON ship.speakership_id = spr.speakership_id
JOIN planning_center.publishing_episodes_relationships er_ship
ON er_ship.relationship_id = ship.speakership_id AND er_ship.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = er_ship.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY sp.speaker_id, sp.formatted_name
ORDER BY most_recent_episode DESC;
```
### Speaker and Series Combination
Which speakers taught which series?
```sql theme={null}
-- Speakers by series
SELECT
s.title as series_title,
sp.formatted_name as speaker_name,
COUNT(e.episode_id) as episodes_in_series,
MIN(e.published_live_at) as first_episode,
MAX(e.published_live_at) as last_episode
FROM planning_center.publishing_series s
JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.relationship_id = s.series_id AND er_series.relationship_type = 'series'
JOIN planning_center.publishing_episodes e ON e.episode_id = er_series.episode_id
JOIN planning_center.publishing_episodes_relationships er_ship
ON er_ship.episode_id = e.episode_id AND er_ship.relationship_type = 'speakerships'
JOIN planning_center.publishing_speakerships ship ON ship.speakership_id = er_ship.relationship_id
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship.speakership_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_speakers sp ON sp.speaker_id = spr.relationship_id
GROUP BY s.series_id, s.title, s.started_at, sp.speaker_id, sp.formatted_name
ORDER BY s.started_at DESC, episodes_in_series DESC;
```
## Content Performance
### Episode View Counts
Track engagement metrics for episodes.
```sql theme={null}
-- Episode performance metrics (library and live watch counts)
-- Note: episode_statistics join uses pattern 'es-' || episode_id
SELECT
e.title as episode_title,
e.published_live_at,
est.library_watch_count,
est.live_watch_count,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as total_watch_count,
s.title as series_title
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships er
ON er.episode_id = e.episode_id AND er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s ON s.series_id = er.relationship_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '3 months'
ORDER BY total_watch_count DESC NULLS LAST
LIMIT 25;
```
### Top Performing Content
Your most viewed episodes.
```sql theme={null}
-- Top 10 most watched episodes of all time
SELECT
e.title,
e.published_live_at,
est.library_watch_count,
est.live_watch_count,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as total_watch_count,
s.title as series_title,
EXTRACT(DAY FROM (CURRENT_DATE - e.published_live_at)) as days_since_published
FROM planning_center.publishing_episodes e
JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships er
ON er.episode_id = e.episode_id AND er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s ON s.series_id = er.relationship_id
WHERE est.library_watch_count IS NOT NULL OR est.live_watch_count IS NOT NULL
ORDER BY total_watch_count DESC
LIMIT 10;
```
### Weekly Engagement Trends
How does engagement change over time?
```sql theme={null}
-- Weekly engagement summary
SELECT
DATE_TRUNC('week', e.published_live_at) as week,
COUNT(DISTINCT e.episode_id) as episodes_published,
SUM(COALESCE(est.library_watch_count, 0)) as total_library_watches,
SUM(COALESCE(est.live_watch_count, 0)) as total_live_watches,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '12 weeks'
GROUP BY week
ORDER BY week DESC;
```
## Channel Distribution
### Channel Overview
See all your distribution channels.
```sql theme={null}
-- List all channels with settings
SELECT
channel_id,
name,
description,
published,
enable_audio,
enable_on_demand_video,
enable_watch_live,
podcast_feed_url
FROM planning_center.publishing_channels
ORDER BY name;
```
### Episodes by Channel
Content distribution across channels.
```sql theme={null}
-- Episode count by channel
SELECT
c.name as channel_name,
COUNT(DISTINCT er.episode_id) as episode_count,
MIN(e.published_live_at) as first_episode,
MAX(e.published_live_at) as latest_episode
FROM planning_center.publishing_channels c
LEFT JOIN planning_center.publishing_episodes_relationships er
ON er.relationship_id = c.channel_id AND er.relationship_type = 'channel'
LEFT JOIN planning_center.publishing_episodes e ON e.episode_id = er.episode_id
GROUP BY c.channel_id, c.name
ORDER BY episode_count DESC;
```
## Resources and Materials
### Episode Resources
Find downloadable materials for episodes.
```sql theme={null}
-- Resources for recent episodes
SELECT
e.title as episode_title,
e.published_live_at,
er.title as resource_title,
er.kind as resource_type,
er.url
FROM planning_center.publishing_episodes e
JOIN planning_center.publishing_episodes_relationships erel
ON erel.episode_id = e.episode_id AND erel.relationship_type = 'episode_resources'
JOIN planning_center.publishing_episode_resources er
ON er.episode_resource_id = erel.relationship_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '1 month'
ORDER BY e.published_live_at DESC, er.title;
```
### Resource Types Summary
What types of resources are you providing?
```sql theme={null}
-- Count resources by type
SELECT
er.kind as resource_type,
COUNT(*) as resource_count,
COUNT(DISTINCT erel.episode_id) as episodes_with_type
FROM planning_center.publishing_episode_resources er
JOIN planning_center.publishing_episodes_relationships erel
ON erel.relationship_id = er.episode_resource_id AND erel.relationship_type = 'episode_resources'
GROUP BY er.kind
ORDER BY resource_count DESC;
```
## Publishing Schedule
### Upcoming Publishing Times
When will content be published next?
```sql theme={null}
-- Next scheduled publishing times by channel
SELECT
c.name as channel_name,
cnt.starts_at as next_publish_at,
EXTRACT(DAY FROM (cnt.starts_at - CURRENT_TIMESTAMP)) as days_until_publish
FROM planning_center.publishing_channel_next_times cnt
JOIN planning_center.publishing_channels_relationships cr
ON cr.relationship_id = cnt.channel_next_time_id AND cr.relationship_type = 'next_times'
JOIN planning_center.publishing_channels c ON c.channel_id = cr.channel_id
WHERE cnt.starts_at > CURRENT_TIMESTAMP
ORDER BY cnt.starts_at;
```
### Episode Publishing Schedule
When episodes are scheduled to go live.
```sql theme={null}
-- Episode publishing schedule (upcoming episode times)
SELECT
e.title as episode_title,
et.starts_at as scheduled_time,
et.current_state as publish_status,
e.published_live_at as actual_publish_time
FROM planning_center.publishing_episode_times et
JOIN planning_center.publishing_episodes_relationships er
ON er.relationship_id = et.episode_time_id AND er.relationship_type = 'episode_times'
JOIN planning_center.publishing_episodes e ON e.episode_id = er.episode_id
WHERE et.starts_at >= CURRENT_DATE
ORDER BY et.starts_at;
```
## Quick Metrics
### Publishing Summary Dashboard
Key metrics at a glance.
```sql theme={null}
-- Publishing dashboard metrics
SELECT
(SELECT COUNT(*) FROM planning_center.publishing_episodes
WHERE published_live_at IS NOT NULL) as total_episodes,
(SELECT COUNT(*) FROM planning_center.publishing_series
WHERE published = true) as total_series,
(SELECT COUNT(*) FROM planning_center.publishing_speakers) as total_speakers,
(SELECT COUNT(*) FROM planning_center.publishing_channels) as total_channels,
(SELECT SUM(library_watch_count) + SUM(live_watch_count)
FROM planning_center.publishing_episode_statistics) as total_watches,
(SELECT COUNT(*) FROM planning_center.publishing_episodes
WHERE published_live_at >= CURRENT_DATE - INTERVAL '30 days') as episodes_last_30_days,
(SELECT COUNT(*) FROM planning_center.publishing_episode_resources) as total_resources;
```
### Monthly Publishing Cadence
How consistent is your publishing schedule?
```sql theme={null}
-- Monthly publishing statistics
SELECT
DATE_TRUNC('month', e.published_live_at) as month,
COUNT(DISTINCT e.episode_id) as episodes_published,
COUNT(DISTINCT er.relationship_id) as active_series,
COUNT(DISTINCT DATE_TRUNC('week', e.published_live_at)) as weeks_with_content,
ARRAY_AGG(DISTINCT EXTRACT(DOW FROM e.published_live_at)) as publishing_days
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episodes_relationships er
ON er.episode_id = e.episode_id AND er.relationship_type = 'series'
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '12 months'
AND e.published_live_at IS NOT NULL
GROUP BY month
ORDER BY month DESC;
```
## Tips for Using These Queries
1. **Customize Date Ranges** - Change INTERVAL values to match your needs
2. **Filter by Channel** - Add `AND channel_id = 'YOUR_CHANNEL_ID'` to focus on specific channels
3. **Add Speaker Filters** - Join with speakerships to analyze specific speakers
4. **Export Results** - Use your SQL client's export feature for reports
5. **Schedule Reports** - Many of these work great as automated weekly/monthly reports
## Next Steps
Ready for more complex analysis? Check out:
* [Advanced Queries](/planning-center/publishing/advanced-queries) - Complex joins and analytics
* [Reporting Examples](/planning-center/publishing/reporting-examples) - Production-ready reports
* [Data Model](/planning-center/publishing/data-model) - Complete table reference
***
*Start simple, build confidence, then explore more complex queries as you grow!*
# Planning Center Publishing Data Model
Source: https://docs.getparable.io/planning-center/publishing/data-model
Complete reference for all Planning Center Publishing tables in Parable: episodes, series, speakers, channels, and the relationships between them.
This document provides **complete** documentation of ALL tables in the Planning Center Publishing data model in Parable, including all entity tables and relationship tables with full field definitions.
## Overview
The Publishing module manages your church's media content distribution, containing:
* **14 entity tables** - Episodes, series, channels, speakers, and supporting data
* **4 relationship tables** - Linking episodes, channels, series, and speakerships to related entities
* **Comprehensive metrics** - View counts, downloads, and engagement tracking
## Visual Data Model
The diagram below shows the core entities and their relationships in the Publishing module. Use it as a visual reference while exploring the detailed table definitions below.
### Core Entity Relationships
[Open diagram in new tab →](/diagrams/planning-center/publishing-data-model-01.svg)
### Key Relationships Explained
**Content Hierarchy:**
1. `CHANNEL` is the top-level podcast/media feed
2. `SERIES` organizes episodes by topic or theme
3. `EPISODE` is the individual sermon, message, or content piece
4. Hierarchy: Channel → Series → Episode
**Publishing Schedule:**
* `CHANNEL_DEFAULT_TIME` defines recurring publish schedule
* `CHANNEL_NEXT_TIME` overrides next publish date/time
* `EPISODE_TIME` tracks when specific episodes were/will be published
* Supports both recurring schedules and one-off publications
**Media Resources:**
* `EPISODE_RESOURCE` stores multiple formats per episode
* Kinds include: audio, video, PDF notes, etc.
* Each resource has URL and file size tracking
* Single episode can have audio, video, and supplementary files
**Speaker Management:**
* `SPEAKER` defines individuals who present content
* `SPEAKERSHIP` links episodes to speakers (many-to-many)
* Single episode can have multiple speakers
* Speakers can appear in multiple episodes/series
**Analytics:**
* `EPISODE_STATISTIC` tracks engagement metrics
* Metrics: library watch count, live watch count, time periods
* Historical tracking with date ranges
* Enables trend analysis and content performance review
**Templates & Onboarding:**
* `NOTE_TEMPLATE` provides reusable sermon note formats
* `ONBOARDING` tracks publishing setup completion
* `ORGANIZATION` links to Planning Center organization settings
**Generic Relationship Pattern:**
* Inter-entity links are stored in `*_relationships` tables
* Each relationship table holds a parent entity ID, `relationship_type`, and `relationship_id`
* Episode times, episode resources, and channel times belong to their parent via relationship tables
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Publishing module are in the `planning_center` schema. You MUST prefix all table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.publishing_episodes`
❌ INCORRECT: `SELECT * FROM publishing_episodes`
### Row Level Security (RLS)
This database uses Row Level Security (RLS) to automatically filter data based on:
* **tenant\_organization\_id**: You only see data for your current organization
* **system\_status**: You only see 'active' records by default
**DO NOT add these filters to your WHERE clause** - they are applied automatically:
* ❌ `WHERE tenant_organization_id = 1` (unnecessary)
* ❌ `WHERE system_status = 'active'` (unnecessary)
The RLS policies ensure you only access data you're authorized to see, making these filters redundant and potentially causing performance issues.
## Complete Table Inventory
### Core Content Tables
#### 1. `publishing_episodes`
Individual sermons, messages, and content pieces.
| Column | Type | Description |
| ----------------------------------------- | ------------ | ------------------------------ |
| `id` | UUID | Unique internal identifier |
| `episode_id` | VARCHAR(64) | Planning Center episode ID |
| `title` | TEXT | Episode title |
| `description` | TEXT | Episode description |
| `published_live_at` | TIMESTAMP | When episode went live |
| `published_to_library_at` | TIMESTAMP | When added to library |
| `video_url` | TEXT | Primary video URL |
| `video_embed_code` | TEXT | Primary video embed code |
| `video_thumbnail_url` | TEXT | Video thumbnail image |
| `library_video_url` | TEXT | Library video URL |
| `library_video_embed_code` | TEXT | Library video embed code |
| `library_video_thumbnail_url` | TEXT | Library thumbnail |
| `library_audio_url` | TEXT | Library audio URL |
| `library_streaming_service` | VARCHAR(255) | Streaming provider for library |
| `sermon_audio` | JSONB | Audio file metadata |
| `art` | JSONB | Artwork/images metadata |
| `church_center_url` | TEXT | Church Center app URL |
| `stream_type` | VARCHAR(255) | Live/recorded/hybrid |
| `streaming_service` | VARCHAR(255) | Streaming platform |
| `page_actions` | JSONB | Available page actions |
| `needs_library_audio_or_video_url` | BOOLEAN | Missing media flag |
| `needs_notes_template` | BOOLEAN | Missing notes template flag |
| `needs_video_url` | BOOLEAN | Missing video flag |
| `services_plan_remote_identifier` | VARCHAR(64) | Services app plan ID |
| `services_service_type_remote_identifier` | VARCHAR(64) | Service type ID |
| `created_at` | TIMESTAMP | Creation timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
#### 2. `publishing_series`
Sermon series and content collections.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Unique internal identifier |
| `series_id` | VARCHAR(64) | Planning Center series ID |
| `title` | VARCHAR(255) | Series title |
| `description` | TEXT | Series description |
| `art` | JSONB | Series artwork metadata |
| `church_center_url` | VARCHAR(255) | Church Center URL |
| `started_at` | TIMESTAMP | Series start date |
| `ended_at` | TIMESTAMP | Series end date |
| `episodes_count` | INTEGER | Number of episodes |
| `published` | BOOLEAN | Publication status |
| `created_at` | TIMESTAMP | Creation timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
#### 3. `publishing_channels`
Distribution channels and platforms.
| Column | Type | Description |
| ----------------------------------------- | ----------- | ---------------------------- |
| `id` | UUID | Unique internal identifier |
| `channel_id` | VARCHAR(64) | Planning Center channel ID |
| `name` | TEXT | Channel name |
| `description` | TEXT | Channel description |
| `art` | JSONB | Channel artwork metadata |
| `podcast_art` | JSONB | Podcast artwork metadata |
| `podcast_feed_url` | TEXT | Podcast RSS feed URL |
| `podcast_settings` | JSONB | Podcast settings |
| `church_center_url` | TEXT | Church Center URL |
| `url` | TEXT | Channel URL |
| `published` | BOOLEAN | Publication status |
| `position` | INTEGER | Display position |
| `enable_audio` | BOOLEAN | Audio enabled flag |
| `enable_on_demand_video` | BOOLEAN | On-demand video enabled |
| `enable_watch_live` | BOOLEAN | Live watch enabled |
| `general_chat_enabled` | BOOLEAN | Chat enabled flag |
| `group_chat_enabled` | BOOLEAN | Group chat enabled |
| `sermon_notes_enabled` | BOOLEAN | Sermon notes enabled |
| `can_enable_chat` | BOOLEAN | Can enable chat |
| `default_video_duration` | INTEGER | Default video duration |
| `default_video_embed_code` | TEXT | Default video embed code |
| `default_video_url` | TEXT | Default video URL |
| `activate_episode_minutes_before` | INTEGER | Episode activation lead time |
| `services_service_type_remote_identifier` | VARCHAR(64) | Services app reference |
| `created_at` | TIMESTAMP | Creation timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
#### 4. `publishing_speakers`
Speaker profiles and information.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------- |
| `id` | UUID | Unique internal identifier |
| `speaker_id` | VARCHAR(64) | Planning Center speaker ID |
| `first_name` | VARCHAR(255) | Speaker first name |
| `last_name` | VARCHAR(255) | Speaker last name |
| `formatted_name` | VARCHAR(255) | Full formatted name |
| `name_prefix` | VARCHAR(50) | Name prefix (Dr., Rev., etc.) |
| `name_suffix` | VARCHAR(50) | Name suffix |
| `speaker_type` | VARCHAR(50) | Speaker type |
| `avatar_url` | VARCHAR(255) | Speaker avatar/photo URL |
| `episodes_count` | INTEGER | Number of episodes |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
### Relationship Tables
#### 5. `publishing_speakerships`
Links episodes to speakers (junction table).
| Column | Type | Description |
| ------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | UUID | Unique internal identifier |
| `speakership_id` | VARCHAR(64) | Planning Center speakership ID (links to episodes via `publishing_episodes_relationships` with `relationship_type='speakerships'`; links to speakers via `publishing_speakerships_relationships` with `relationship_type='speaker'`) |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
### Resource and Media Tables
#### 6. `publishing_episode_resources`
Files and resources attached to episodes.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | UUID | Unique internal identifier |
| `episode_resource_id` | VARCHAR(64) | Planning Center resource ID (links to episodes via `publishing_episodes_relationships` with `relationship_type='episode_resources'`) |
| `title` | TEXT | Resource title |
| `kind` | VARCHAR(255) | Resource kind |
| `type` | VARCHAR(255) | Resource type |
| `icon` | TEXT | Resource icon |
| `url` | TEXT | Resource URL |
| `featured` | BOOLEAN | Featured resource flag |
| `position` | INTEGER | Display position |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
#### 7. `publishing_note_templates`
Templates for sermon notes and outlines.
| Column | Type | Description |
| ----------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Unique internal identifier |
| `note_template_id` | VARCHAR(64) | Planning Center template ID (links to episodes via `publishing_episodes_relationships` with `relationship_type='note_template'`) |
| `auto_create_free_form_notes` | BOOLEAN | Whether to auto-create free-form notes |
| `enabled` | BOOLEAN | Whether template is enabled |
| `published_at` | TIMESTAMP | When template was published |
| `template` | TEXT | Template content |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
### Scheduling Tables
#### 8. `publishing_episode_times`
Publishing schedule for episodes on different channels.
| Column | Type | Description |
| ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Unique internal identifier |
| `episode_time_id` | VARCHAR(64) | Planning Center time ID (links to episodes via `publishing_episodes_relationships` with `relationship_type='episode_times'`) |
| `starts_at` | TIMESTAMP | Episode start time |
| `ends_at` | TIMESTAMP | Episode end time |
| `current_state` | VARCHAR(50) | Current publish state |
| `streaming_service` | VARCHAR(50) | Streaming service |
| `video_url` | TEXT | Episode video URL |
| `video_embed_code` | TEXT | Video embed code |
| `caveats` | JSONB | Publishing caveats |
| `timestamp_current` | DOUBLE PRECISION | Current playback timestamp in seconds |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
#### 9. `publishing_channel_default_times`
Default publishing schedules per channel.
| Column | Type | Description |
| ------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Unique internal identifier |
| `channel_default_time_id` | VARCHAR(64) | Planning Center default time ID (links to channels via `publishing_channels_relationships` with `relationship_type='channel_default_times'`) |
| `day_of_week` | INTEGER | Day (0=Sunday, 6=Saturday) |
| `hour` | INTEGER | Publish hour |
| `minute` | INTEGER | Publish minute |
| `frequency` | VARCHAR(50) | Recurrence frequency |
| `position` | INTEGER | Display position |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
#### 10. `publishing_channel_next_times`
Upcoming scheduled publishes per channel.
| Column | Type | Description |
| ------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `id` | UUID | Unique internal identifier |
| `channel_next_time_id` | VARCHAR(64) | Planning Center next time ID (links to channels via `publishing_channels_relationships` with `relationship_type='next_times'`) |
| `starts_at` | TIMESTAMP | Next scheduled publish time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
### Analytics Tables
#### 11. `publishing_episode_statistics`
View counts, downloads, and engagement metrics.
| Column | Type | Description | | |
| ------------------------- | ------------ | ------------------------------------------------------ | - | ------------------------- |
| `id` | UUID | Unique internal identifier | | |
| `episode_statistics_id` | VARCHAR(64) | Planning Center statistic ID — follows pattern \`'es-' | | episode\_id\` for joining |
| `title` | VARCHAR(255) | Episode title (snapshot) | | |
| `library_watch_count` | INTEGER | Library watch count | | |
| `live_watch_count` | INTEGER | Live watch count | | |
| `published_live_at` | TIMESTAMP | When episode went live | | |
| `published_to_library_at` | TIMESTAMP | When added to library | | |
| `tenant_organization_id` | INTEGER | Organization identifier | | |
| `system_status` | VARCHAR(50) | Data lifecycle status | | |
| `system_created_at` | TIMESTAMP | System creation time | | |
| `system_updated_at` | TIMESTAMP | System update time | | |
#### 12. `publishing_episode_statistic_times`
Statistics tracked over time periods.
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------------------------------------------------------------------------- |
| `id` | UUID | Unique internal identifier |
| `episode_statistics_id` | VARCHAR(64) | Parent episode statistics ID (matches `publishing_episode_statistics.episode_statistics_id`) |
| `time_id` | VARCHAR(64) | Planning Center time ID |
| `starts_at` | TIMESTAMP | Period start time |
| `watch_count` | INTEGER | Watches in period |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
### Configuration Tables
#### 13. `publishing_organizations`
Organization-level publishing settings.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------- |
| `id` | UUID | Unique internal identifier |
| `organization_id` | VARCHAR(64) | Planning Center org ID |
| `name` | VARCHAR(255) | Organization name |
| `subdomain` | VARCHAR(255) | Organization subdomain |
| `downloads_used` | INTEGER | Number of downloads used |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
### Administrative Tables
#### 14. `publishing_onboardings`
Tracks onboarding process for new channels.
| Column | Type | Description |
| ------------------------ | ----------- | ------------------------------ |
| `id` | UUID | Unique internal identifier |
| `onboarding_id` | VARCHAR(64) | Planning Center onboarding ID |
| `completed` | BOOLEAN | Whether onboarding is complete |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
## Relationship Tables
All relationship tables share this structure: a parent entity ID, `relationship_type` (VARCHAR(50)), and `relationship_id` (VARCHAR(64)) to identify the related record, plus standard system fields.
#### 15. `publishing_channels_relationships`
Links channels to related entities (default times, next times, episodes).
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Unique internal identifier |
| `channel_id` | VARCHAR(64) | Parent channel ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
Common relationship types:
* `channel_default_times` - Links to publishing\_channel\_default\_times
* `next_times` - Links to publishing\_channel\_next\_times
#### 16. `publishing_episodes_relationships`
Links episodes to related entities (series, channels, resources, times, speakerships, note templates).
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Unique internal identifier |
| `episode_id` | VARCHAR(64) | Parent episode ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
Common relationship types:
* `series` - Links to publishing\_series
* `channel` - Links to publishing\_channels
* `episode_resources` - Links to publishing\_episode\_resources
* `episode_times` - Links to publishing\_episode\_times
* `speakerships` - Links to publishing\_speakerships
* `note_template` - Links to publishing\_note\_templates
#### 17. `publishing_series_relationships`
Links series to related entities (channels, episodes).
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Unique internal identifier |
| `series_id` | VARCHAR(64) | Parent series ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
#### 18. `publishing_speakerships_relationships`
Links speakerships to related entities (speakers, episodes).
| Column | Type | Description |
| ------------------------ | ----------- | -------------------------- |
| `id` | UUID | Unique internal identifier |
| `speakership_id` | VARCHAR(64) | Parent speakership ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data lifecycle status |
| `system_created_at` | TIMESTAMP | System creation time |
| `system_updated_at` | TIMESTAMP | System update time |
Common relationship types:
* `speaker` - Links to publishing\_speakers
* `episode` - Links to publishing\_episodes
## System Fields
All tables include these system fields for data management:
* `tenant_organization_id` - Multi-tenant organization identifier
* `system_status` - Data lifecycle status (`active`, `transferring`, `stale`)
* `system_created_at` - When the record was created in Parable
* `system_updated_at` - When the record was last updated in Parable
## Row Level Security
All tables implement Row Level Security (RLS) to ensure tenant isolation:
```sql theme={null}
-- Example RLS policy
CREATE POLICY tenant_read_only ON planning_center.publishing_episodes
FOR SELECT
TO PUBLIC
USING (
system_status = 'active'
AND EXISTS (
SELECT 1 FROM public.tenant_dsn_credentials dsn
WHERE dsn.role_name = CURRENT_ROLE
AND dsn.tenant_organization_id = planning_center.publishing_episodes.tenant_organization_id
)
);
```
## Data Integrity Rules
1. **Schema Qualification**: Always use `planning_center.` prefix for all table references
2. **Row Level Security**: RLS automatically handles multi-tenancy and status filtering - do not add manual filters
3. **Monetary Values**: Any monetization or purchase amount columns are stored in cents - divide by 100.0 for display
4. **Media Flags**: Use booleans like `needs_video_url` and `needs_library_audio_or_video_url` to identify missing assets instead of relying on `system_status`
5. **Relationship Tables**: All inter-entity links are stored in `*_relationships` tables — use `publishing_episodes_relationships`, `publishing_channels_relationships`, etc. for joins
## Common Mistakes to Avoid
1. **Missing Schema Prefix**
* ❌ `FROM publishing_episodes`
* ✅ `FROM planning_center.publishing_episodes`
2. **Adding Redundant RLS Filters**
* ❌ `WHERE tenant_organization_id = 1 AND system_status = 'active'`
* ✅ Trust RLS to handle this automatically
3. **Joining Without Schema**
* ❌ `JOIN publishing_series s ON ...`
* ✅ `JOIN planning_center.publishing_series s ON ...`
4. **Skipping Currency Conversion**
* ❌ `SELECT purchase_price_cents as purchase_price`
* ✅ `SELECT purchase_price_cents / 100.0 as purchase_price`
## Performance Considerations
1. **Indexes**: All tables have optimized indexes on:
* Primary keys and entity IDs
* Join columns and foreign keys
* Date columns for time-based queries
2. **Query Optimization**:
* Always use the `planning_center.` schema prefix
* RLS handles tenant and status filtering automatically
* Filter by publication state or media availability when relevant
* Consider CTEs for aggregating view/download metrics
* Use relationship tables for all cross-entity joins
## Data Synchronization
Publishing data is synchronized through Temporal workflows:
1. **Master Workflow** - Orchestrates all child workflows
2. **Independent Entities** - Channels, Series, Speakers, Organizations
3. **Dependent Entities** - Episodes (depends on channels)
4. **Related Data** - Resources, times, statistics (depends on episodes)
5. **Junction Tables** - Speakerships linking episodes to speakers
## Usage Tips
1. **Trust RLS for active data** - Skip manual `system_status` or tenant filters
2. **Join through relationship tables** - Use `publishing_episodes_relationships`, `publishing_channels_relationships`, etc.
3. **Consider NULL values** - Many fields are optional
4. **Use JSONB operators** for nested data in `art`, `sermon_audio`, `page_actions` fields
5. **Aggregate statistics** over time periods for trends
## Common Join Patterns
### Episodes with Series and Speakers
```sql theme={null}
SELECT
e.title as episode_title,
s.title as series_title,
sp.formatted_name as speaker_name
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s ON s.series_id = er_series.relationship_id
LEFT JOIN planning_center.publishing_episodes_relationships er_ship
ON er_ship.episode_id = e.episode_id AND er_ship.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = er_ship.relationship_id AND spr.relationship_type = 'speaker'
LEFT JOIN planning_center.publishing_speakers sp ON sp.speaker_id = spr.relationship_id;
```
### Episodes with Statistics and Resources
```sql theme={null}
SELECT
e.title,
est.library_watch_count,
est.live_watch_count,
er.title as resource_title,
er.url as resource_url
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships er_res
ON er_res.episode_id = e.episode_id AND er_res.relationship_type = 'episode_resources'
LEFT JOIN planning_center.publishing_episode_resources er ON er.episode_resource_id = er_res.relationship_id;
```
## Data Quality Notes
* Episode `published_live_at` indicates published content
* Series may have NULL `ended_at` for ongoing series
* Statistics are point-in-time snapshots (`episode_statistics_id = 'es-' || episode_id`)
* Speakerships link episodes to speakers via `publishing_episodes_relationships` and `publishing_speakerships_relationships`
* Episodes link to channels, series, and resources via `publishing_episodes_relationships`
***
*This data model enables comprehensive media analytics and content management for your church's publishing ministry.*
# Planning Center Publishing SQL Queries
Source: https://docs.getparable.io/planning-center/publishing/overview
Query Planning Center Publishing data with SQL to analyze sermon series, speakers, media distribution, and how your content reaches its audience.
## Share Your Message With The World
Planning Center Publishing is your church's media hub - managing sermons, series, speakers, and distribution channels. With Parable's SQL access to Publishing data, you can analyze content performance, track engagement metrics, optimize distribution strategies, and ensure your message reaches your community effectively.
## Quick Start
Ready to explore your publishing data? Here's your first query to see recent episodes and their engagement:
```sql theme={null}
-- See your 10 most recent published episodes with view counts
SELECT
e.episode_id,
e.title,
s.title as series_title,
e.published_live_at,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as total_watches,
e.video_url,
c.name as channel_name
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s
ON s.series_id = er_series.relationship_id
LEFT JOIN planning_center.publishing_episodes_relationships er_channel
ON er_channel.episode_id = e.episode_id AND er_channel.relationship_type = 'channel'
LEFT JOIN planning_center.publishing_channels c
ON c.channel_id = er_channel.relationship_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
ORDER BY e.published_live_at DESC
LIMIT 10;
```
## What You Can Do With Publishing Queries
### 📺 Content Performance Analytics
* Track view counts and download metrics across episodes
* Analyze engagement patterns by series, speaker, or topic
* Identify your most popular content and distribution channels
* Monitor audience growth and retention trends
### 🎙️ Speaker Management
* Track speaker frequency and scheduling
* Analyze topic coverage by speaker
* Identify guest speakers vs regular teaching team
* Plan speaker rotations and sabbaticals
### 📅 Series Planning
* Analyze optimal series length based on engagement
* Track series performance over time
* Identify seasonal patterns in content consumption
* Plan future series based on historical data
### 🌐 Multi-Channel Distribution
* Monitor performance across different platforms
* Optimize publishing schedules for maximum reach
* Track channel-specific engagement metrics
* Identify best channels for different content types
### 📊 Ministry Impact Measurement
* Correlate online engagement with in-person attendance
* Track sermon series impact on giving patterns
* Measure content reach beyond regular attendees
* Analyze biblical text coverage across teaching
## Available Tables
Your Planning Center Publishing data is organized into these primary tables:
| Table | What It Contains | Key Use Cases |
| ---------------------------------------------- | ------------------------------------- | ------------------------------------------ |
| `publishing_episodes` | Individual sermons and content pieces | Core content records, titles, descriptions |
| `publishing_series` | Sermon series and collections | Series organization, themes, date ranges |
| `publishing_channels` | Distribution channels and platforms | YouTube, podcast, website channels |
| `publishing_speakers` | Speaker profiles and information | Teaching team, guest speakers |
| `publishing_speakerships` | Links episodes to speakers | Who spoke on which episode |
| `publishing_organizations` | Organization-level settings | Global publishing configuration |
| `publishing_episode_resources` | Files and resources for episodes | Sermon notes, slides, handouts |
| `publishing_episode_times` | Publishing schedule for episodes | When content goes live on each channel |
| `publishing_episode_statistics` | View and download metrics | Engagement tracking, performance analysis |
| `publishing_episode_statistic_times` | Statistics over time periods | Trending data, growth metrics |
| `publishing_note_templates` | Templates for sermon notes | Standardized note formats |
| `publishing_channel_default_times` | Default publishing schedules | Standard release times per channel |
| `publishing_channel_default_episode_resources` | Default resources per channel | Standard attachments for channels |
| `publishing_channel_next_times` | Upcoming scheduled publishes | Future content calendar |
| `publishing_onboardings` | Onboarding process tracking | New channel setup progress |
| `publishing_jolt_tokens` | Authentication tokens | API access management |
| `publishing_page_restrictions` | Content access restrictions | Member-only content, premium access |
## Understanding Relationships
Publishing data includes several key relationships:
* **Episodes to Series**: Episodes belong to series through `series_id`
* **Episodes to Channels**: Episodes are published to channels via `channel_id`
* **Episodes to Speakers**: Connected through the `publishing_speakerships` junction table
* **Episodes to Resources**: Linked via `episode_id` in `publishing_episode_resources`
* **Episodes to Statistics**: Performance metrics linked by `episode_id`
## Key Concepts
### Episode Status
* `published_live_at` - When the episode went live
* `published_to_library_at` - When added to the content library
* Episodes without these dates are draft or scheduled
### Distribution Channels
Different platforms where content is published:
* Church website
* YouTube channel
* Podcast platforms
* Church Center app
* Social media channels
### Content Types
* **Sermons** - Weekly messages
* **Series** - Multi-week teaching themes
* **Special Events** - Holiday services, conferences
* **Resources** - Study guides, sermon notes
### Metrics and Analytics
* `view_count` - Total views across platforms
* `download_count` - Audio/video downloads
* `unique_viewers` - Distinct audience members
* Statistics tracked over time for trending
## Next Steps
📚 **New to SQL?** Start with [Basic Queries](/planning-center/publishing/basic-queries) for simple, powerful queries you can use today.
🚀 **Ready for More?** Check out [Advanced Queries](/planning-center/publishing/advanced-queries) for complex analysis and reporting.
📊 **Need Reports?** See [Reporting Examples](/planning-center/publishing/reporting-examples) for complete, production-ready reports.
🔍 **Want Details?** Review the [Data Model](/planning-center/publishing/data-model) for complete table documentation.
## Common Questions
### How do I find episodes from a specific series?
```sql theme={null}
SELECT
e.title as episode_title,
e.published_live_at,
s.title as series_title
FROM planning_center.publishing_episodes e
JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
JOIN planning_center.publishing_series s
ON s.series_id = er_series.relationship_id
WHERE s.title LIKE '%Grace%'
ORDER BY e.published_live_at;
```
### How do I see which speakers have taught recently?
```sql theme={null}
SELECT
sp.formatted_name as speaker_name,
COUNT(DISTINCT e.episode_id) as episode_count,
MAX(e.published_live_at) as most_recent
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_speakerships ship
ON ship.speakership_id = spr.speakership_id
JOIN planning_center.publishing_episodes_relationships er_ship
ON er_ship.relationship_id = ship.speakership_id AND er_ship.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e
ON e.episode_id = er_ship.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY sp.speaker_id, sp.formatted_name
ORDER BY episode_count DESC;
```
### How do I track content performance over time?
```sql theme={null}
SELECT
DATE_TRUNC('week', published_live_at) as week,
SUM(live_watch_count) as total_live_watches,
SUM(library_watch_count) as total_library_watches,
COUNT(DISTINCT episode_statistics_id) as episodes_tracked
FROM planning_center.publishing_episode_statistics
WHERE published_live_at >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY DATE_TRUNC('week', published_live_at)
ORDER BY week DESC;
```
### What's the difference between channels and series?
* **Channels** are distribution platforms (YouTube, Podcast, Website)
* **Series** are content collections (sermon series, teaching themes)
* An episode belongs to one series but can be published to multiple channels
### How do I find resources attached to episodes?
```sql theme={null}
SELECT
e.title as episode_title,
er.title as resource_name,
er.kind as resource_type,
er.url
FROM planning_center.publishing_episodes e
JOIN planning_center.publishing_episodes_relationships err
ON err.episode_id = e.episode_id AND err.relationship_type = 'episode_resources'
JOIN planning_center.publishing_episode_resources er
ON er.episode_resource_id = err.relationship_id
WHERE e.episode_id = 'YOUR_EPISODE_ID';
```
## Tips for Success
1. **Join Through Relationship Tables** - Use `publishing_episodes_relationships`, `publishing_speakerships_relationships` etc. for relationships
2. **Filter by Dates** - Use `published_live_at` to focus on published content
3. **Aggregate Metrics** - Sum statistics across time periods for trends
4. **Consider Channels** - Different channels may have different performance
5. **Track Over Time** - Use `episode_statistic_times` for historical trends
## Integration Opportunities
Combine Publishing data with other Planning Center modules:
* **With People**: Track online viewers who become visitors
* **With Giving**: Analyze giving patterns during specific series
* **With Groups**: Connect sermon topics to small group curricula
* **With Services**: Link sermon planning to worship service elements
* **With Check-ins**: Correlate online viewing with attendance
## Getting Help
* 🐛 Found an issue? Report it at [github.com/getparable/parable-api/issues](https://github.com/getparable/parable-api/issues)
* 📖 Need more examples? Check our other query guides in this folder
* 💬 Have questions? Reach out to your Parable support team
***
*Your message matters. Let data help you share it more effectively.*
# Planning Center Publishing Report Examples
Source: https://docs.getparable.io/planning-center/publishing/reporting-examples
Production-ready Publishing reports for leadership: series performance, speaker frequency, and distribution summaries ready to export or schedule.
Production-ready report templates for Publishing data. These queries are designed to be scheduled, exported, and shared with leadership.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Publishing module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your reports.
✅ CORRECT: `SELECT * FROM planning_center.publishing_episodes`
❌ INCORRECT: `SELECT * FROM publishing_episodes`
### Row Level Security (RLS)
Row Level Security automatically governs:
* **tenant\_organization\_id** – restricts results to your organization
* **system\_status** – active records returned by default
**Do not add these filters manually**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus on media analytics, engagement trends, and channel performance while trusting RLS for tenancy and system status.
## Executive Dashboard Reports
### Monthly Publishing Executive Summary
Complete monthly overview for leadership.
```sql theme={null}
-- Monthly Executive Publishing Summary Report
WITH current_month_metrics AS (
SELECT
DATE_TRUNC('month', CURRENT_DATE) as report_month,
COUNT(DISTINCT e.episode_id) as episodes_published,
COUNT(DISTINCT ser_er.relationship_id) as active_series,
COUNT(DISTINCT spr.relationship_id) as unique_speakers,
COUNT(DISTINCT ch_er.relationship_id) as active_channels,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode,
COUNT(DISTINCT res_er.relationship_id) as resources_created
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
LEFT JOIN planning_center.publishing_episodes_relationships ch_er
ON ch_er.episode_id = e.episode_id AND ch_er.relationship_type = 'channel'
LEFT JOIN planning_center.publishing_episodes_relationships res_er
ON res_er.episode_id = e.episode_id AND res_er.relationship_type = 'episode_resources'
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE)
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
),
previous_month_metrics AS (
SELECT
COUNT(DISTINCT e.episode_id) as episodes_published,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month')
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE)
),
year_ago_metrics AS (
SELECT
COUNT(DISTINCT e.episode_id) as episodes_published,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 year')
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 year') + INTERVAL '1 month'
),
top_episodes AS (
SELECT
e.title,
COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0) as watch_count,
s.title as series_title
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s ON s.series_id = er_series.relationship_id
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE)
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
ORDER BY watch_count DESC NULLS LAST
LIMIT 3
)
SELECT
TO_CHAR(cm.report_month, 'FMMonth YYYY') as reporting_period,
'=== CONTENT PRODUCTION ===' as section_1,
cm.episodes_published as episodes_published_this_month,
pm.episodes_published as episodes_published_last_month,
ROUND(((cm.episodes_published::numeric - pm.episodes_published) /
NULLIF(pm.episodes_published, 0)) * 100, 1) as episode_change_pct,
cm.active_series as active_series_count,
cm.unique_speakers as unique_speakers_count,
cm.resources_created as resources_created_count,
'=== ENGAGEMENT METRICS ===' as section_2,
cm.total_watches as total_watches_this_month,
pm.total_watches as total_watches_last_month,
ROUND(((cm.total_watches::numeric - pm.total_watches) /
NULLIF(pm.total_watches, 0)) * 100, 1) as watch_growth_mom_pct,
ya.total_watches as total_watches_year_ago,
ROUND(((cm.total_watches::numeric - ya.total_watches) /
NULLIF(ya.total_watches, 0)) * 100, 1) as watch_growth_yoy_pct,
ROUND(cm.avg_watches_per_episode::numeric, 0) as avg_watches_per_episode,
'=== TOP PERFORMING CONTENT ===' as section_3,
(SELECT STRING_AGG(title || ' (' || COALESCE(watch_count::text, 'N/A') || ' watches)', ', '
ORDER BY watch_count DESC NULLS LAST) FROM top_episodes) as top_3_episodes,
'=== DISTRIBUTION ===' as section_4,
cm.active_channels as active_channel_count
FROM current_month_metrics cm
CROSS JOIN previous_month_metrics pm
CROSS JOIN year_ago_metrics ya;
```
### Quarterly Publishing Performance Report
Comprehensive quarterly analysis for board meetings.
```sql theme={null}
-- Quarterly Publishing Performance Report
WITH quarterly_data AS (
SELECT
DATE_TRUNC('quarter', e.published_live_at) as quarter,
COUNT(DISTINCT e.episode_id) as episodes,
COUNT(DISTINCT ser_er.relationship_id) as series,
COUNT(DISTINCT spr.relationship_id) as speakers,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
COUNT(DISTINCT res_er.relationship_id) as resources
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
LEFT JOIN planning_center.publishing_episodes_relationships res_er
ON res_er.episode_id = e.episode_id AND res_er.relationship_type = 'episode_resources'
WHERE e.published_live_at >= DATE_TRUNC('quarter', CURRENT_DATE - INTERVAL '1 year')
GROUP BY quarter
),
current_quarter AS (
SELECT * FROM quarterly_data
WHERE quarter = DATE_TRUNC('quarter', CURRENT_DATE)
),
previous_quarter AS (
SELECT * FROM quarterly_data
WHERE quarter = DATE_TRUNC('quarter', CURRENT_DATE - INTERVAL '3 months')
),
year_ago_quarter AS (
SELECT * FROM quarterly_data
WHERE quarter = DATE_TRUNC('quarter', CURRENT_DATE - INTERVAL '1 year')
)
SELECT
'=== QUARTERLY PUBLISHING REPORT ===' as report_header,
TO_CHAR(cq.quarter, 'Q YYYY') as current_quarter,
'' as blank_line_1,
'--- Content Production ---' as production_header,
cq.episodes as episodes_this_quarter,
pq.episodes as episodes_last_quarter,
yaq.episodes as episodes_year_ago_quarter,
ROUND(((cq.episodes::numeric - pq.episodes) / NULLIF(pq.episodes, 0)) * 100, 1) as episode_qoq_change_pct,
ROUND(((cq.episodes::numeric - yaq.episodes) / NULLIF(yaq.episodes, 0)) * 100, 1) as episode_yoy_change_pct,
'' as blank_line_2,
'--- Engagement Metrics ---' as engagement_header,
cq.watches as total_watches_this_quarter,
ROUND(cq.avg_watches::numeric, 0) as avg_watches_per_episode,
ROUND(((cq.watches::numeric - pq.watches) / NULLIF(pq.watches, 0)) * 100, 1) as watch_qoq_change_pct,
ROUND(((cq.watches::numeric - yaq.watches) / NULLIF(yaq.watches, 0)) * 100, 1) as watch_yoy_change_pct,
'' as blank_line_3,
'--- Resource Metrics ---' as resource_header,
cq.series as active_series_count,
cq.speakers as unique_speakers_count,
cq.resources as resources_created_count,
ROUND(cq.resources::numeric / NULLIF(cq.episodes, 0), 2) as resources_per_episode,
'' as blank_line_4,
'--- Performance Indicators ---' as performance_header,
CASE
WHEN cq.watches > pq.watches * 1.1 THEN 'Strong Growth'
WHEN cq.watches > pq.watches THEN 'Moderate Growth'
WHEN cq.watches > pq.watches * 0.9 THEN 'Stable'
ELSE 'Declining'
END as quarter_performance_rating
FROM current_quarter cq
CROSS JOIN previous_quarter pq
CROSS JOIN year_ago_quarter yaq;
```
## Content Analysis Reports
### Sermon Series Performance Report
Detailed analysis of each series.
```sql theme={null}
-- Sermon Series Performance Analysis Report
WITH series_metrics AS (
SELECT
s.series_id,
s.title as series_title,
s.description,
s.started_at,
s.ended_at,
s.episodes_count as planned_episodes,
COUNT(DISTINCT e.episode_id) as actual_episodes,
MIN(e.published_live_at) as first_episode_date,
MAX(e.published_live_at) as last_episode_date,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode,
MAX(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as best_episode_watches,
MIN(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as worst_episode_watches,
STDDEV(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as watch_stddev,
COUNT(DISTINCT spr.relationship_id) as speaker_count,
STRING_AGG(DISTINCT sp.formatted_name, ', ' ORDER BY sp.formatted_name) as speakers,
COUNT(DISTINCT res_er.relationship_id) as total_resources
FROM planning_center.publishing_series s
LEFT JOIN planning_center.publishing_episodes_relationships ep_er
ON ep_er.relationship_id = s.series_id AND ep_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes e ON e.episode_id = ep_er.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
LEFT JOIN planning_center.publishing_speakers sp ON sp.speaker_id = spr.relationship_id
LEFT JOIN planning_center.publishing_episodes_relationships res_er
ON res_er.episode_id = e.episode_id AND res_er.relationship_type = 'episode_resources'
WHERE s.published = true
GROUP BY s.series_id, s.title, s.description, s.started_at,
s.ended_at, s.episodes_count
),
series_rankings AS (
SELECT
*,
RANK() OVER (ORDER BY total_watches DESC) as watch_rank,
RANK() OVER (ORDER BY avg_watches_per_episode DESC) as avg_watch_rank,
CASE
WHEN ended_at IS NULL THEN 'Ongoing'
WHEN ended_at > CURRENT_DATE THEN 'Upcoming'
ELSE 'Completed'
END as series_status,
EXTRACT(DAY FROM (COALESCE(ended_at, CURRENT_DATE) - started_at)) as duration_days
FROM series_metrics
)
SELECT
series_title,
series_status,
TO_CHAR(started_at, 'Mon DD, YYYY') as start_date,
TO_CHAR(ended_at, 'Mon DD, YYYY') as end_date,
actual_episodes || '/' || planned_episodes as episode_progress,
duration_days as series_duration_days,
speakers,
speaker_count,
total_watches,
watch_rank as watch_ranking,
ROUND(avg_watches_per_episode::numeric, 0) as avg_watches,
avg_watch_rank as avg_watch_ranking,
best_episode_watches,
worst_episode_watches,
ROUND(watch_stddev::numeric, 0) as watch_consistency_score,
total_resources,
ROUND(total_resources::numeric / NULLIF(actual_episodes, 0), 1) as resources_per_episode,
CASE
WHEN avg_watches_per_episode > 100 THEN 'High Engagement'
WHEN avg_watches_per_episode > 50 THEN 'Good Engagement'
WHEN avg_watches_per_episode > 20 THEN 'Moderate Engagement'
ELSE 'Low Engagement'
END as performance_rating
FROM series_rankings
ORDER BY
CASE series_status
WHEN 'Ongoing' THEN 1
WHEN 'Upcoming' THEN 2
ELSE 3
END,
started_at DESC;
```
### Biblical Text Coverage Report
Track scripture coverage across teaching.
```sql theme={null}
-- Biblical Text Coverage Analysis Report
-- Note: This assumes you track scripture references in episode descriptions or a separate table
WITH scripture_extraction AS (
SELECT
e.episode_id,
e.title,
e.description,
e.published_live_at,
s.title as series_title,
-- Extract potential scripture references from description
-- This is a simplified pattern - enhance based on your needs
REGEXP_MATCHES(
e.description,
'(Genesis|Exodus|Leviticus|Numbers|Deuteronomy|Joshua|Judges|Ruth|Samuel|Kings|Chronicles|Ezra|Nehemiah|Esther|Job|Psalms|Proverbs|Ecclesiastes|Song|Isaiah|Jeremiah|Lamentations|Ezekiel|Daniel|Hosea|Joel|Amos|Obadiah|Jonah|Micah|Nahum|Habakkuk|Zephaniah|Haggai|Zechariah|Malachi|Matthew|Mark|Luke|John|Acts|Romans|Corinthians|Galatians|Ephesians|Philippians|Colossians|Thessalonians|Timothy|Titus|Philemon|Hebrews|James|Peter|John|Jude|Revelation)\s+\d+',
'gi'
) as scripture_refs
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episodes_relationships er_series
ON er_series.episode_id = e.episode_id AND er_series.relationship_type = 'series'
LEFT JOIN planning_center.publishing_series s ON s.series_id = er_series.relationship_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '1 year'
AND e.description IS NOT NULL
),
scripture_summary AS (
SELECT
CASE
WHEN scripture_refs[1] IN ('Genesis','Exodus','Leviticus','Numbers','Deuteronomy') THEN 'Pentateuch'
WHEN scripture_refs[1] IN ('Joshua','Judges','Ruth','Samuel','Kings','Chronicles','Ezra','Nehemiah','Esther') THEN 'Historical Books'
WHEN scripture_refs[1] IN ('Job','Psalms','Proverbs','Ecclesiastes','Song') THEN 'Wisdom Literature'
WHEN scripture_refs[1] IN ('Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi') THEN 'Prophets'
WHEN scripture_refs[1] IN ('Matthew','Mark','Luke','John') THEN 'Gospels'
WHEN scripture_refs[1] = 'Acts' THEN 'Acts'
WHEN scripture_refs[1] IN ('Romans','Corinthians','Galatians','Ephesians','Philippians','Colossians','Thessalonians','Timothy','Titus','Philemon') THEN 'Pauline Epistles'
WHEN scripture_refs[1] IN ('Hebrews','James','Peter','John','Jude') THEN 'General Epistles'
WHEN scripture_refs[1] = 'Revelation' THEN 'Revelation'
ELSE 'Other'
END as book_category,
scripture_refs[1] as book_name,
COUNT(DISTINCT episode_id) as episode_count,
STRING_AGG(DISTINCT series_title, ', ') as series_using_book
FROM scripture_extraction
WHERE scripture_refs IS NOT NULL
GROUP BY book_category, scripture_refs[1]
)
SELECT
book_category,
COUNT(DISTINCT book_name) as books_covered,
SUM(episode_count) as total_episodes,
STRING_AGG(book_name || ' (' || episode_count || ')', ', ' ORDER BY episode_count DESC) as books_and_frequency,
ROUND(SUM(episode_count)::numeric / 52, 1) as episodes_per_week_avg
FROM scripture_summary
GROUP BY book_category
ORDER BY total_episodes DESC;
```
## Speaker Reports
### Speaker Performance Dashboard
Comprehensive speaker metrics and analysis.
```sql theme={null}
-- Speaker Performance Dashboard Report
WITH speaker_base_metrics AS (
SELECT
sp.speaker_id,
sp.formatted_name as speaker_name,
COUNT(DISTINCT e.episode_id) as total_episodes,
COUNT(DISTINCT ser_er.relationship_id) as series_count,
MIN(e.published_live_at) as first_episode,
MAX(e.published_live_at) as latest_episode,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
STDDEV(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as watch_consistency
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.relationship_id = spr.speakership_id AND ship_er.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = ship_er.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
WHERE e.published_live_at IS NOT NULL
GROUP BY sp.speaker_id, sp.formatted_name
),
speaker_recent_metrics AS (
SELECT
sp.speaker_id,
COUNT(DISTINCT e.episode_id) as recent_episodes,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as recent_avg_watches,
STRING_AGG(e.title, ', ' ORDER BY e.published_live_at DESC) as recent_episode_titles
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.relationship_id = spr.speakership_id AND ship_er.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = ship_er.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '3 months'
GROUP BY sp.speaker_id
),
speaker_rankings AS (
SELECT
sbm.*,
srm.recent_episodes,
srm.recent_avg_watches,
srm.recent_episode_titles,
RANK() OVER (ORDER BY sbm.total_episodes DESC) as episode_rank,
RANK() OVER (ORDER BY sbm.avg_watches DESC NULLS LAST) as watch_rank,
RANK() OVER (ORDER BY sbm.total_watches DESC NULLS LAST) as total_watch_rank,
EXTRACT(MONTH FROM AGE(CURRENT_DATE, sbm.latest_episode)) as months_since_last_episode
FROM speaker_base_metrics sbm
LEFT JOIN speaker_recent_metrics srm
ON sbm.speaker_id = srm.speaker_id
)
SELECT
speaker_name,
CASE
WHEN months_since_last_episode = 0 THEN 'Active'
WHEN months_since_last_episode <= 3 THEN 'Recent'
WHEN months_since_last_episode <= 6 THEN 'Occasional'
ELSE 'Inactive'
END as speaker_status,
total_episodes,
episode_rank as episode_ranking,
series_count,
TO_CHAR(first_episode, 'Mon YYYY') as teaching_since,
TO_CHAR(latest_episode, 'Mon DD, YYYY') as last_taught,
months_since_last_episode,
COALESCE(recent_episodes, 0) as episodes_last_3_months,
ROUND(avg_watches::numeric, 0) as avg_watches,
watch_rank as avg_watch_ranking,
total_watches,
total_watch_rank as total_watch_ranking,
ROUND(watch_consistency::numeric, 0) as consistency_score,
CASE
WHEN avg_watches > 50 THEN 'High Engagement'
WHEN avg_watches > 20 THEN 'Good Engagement'
WHEN avg_watches > 5 THEN 'Moderate Engagement'
ELSE 'Developing'
END as performance_tier,
LEFT(recent_episode_titles, 100) as recent_episodes_sample
FROM speaker_rankings
ORDER BY
CASE
WHEN months_since_last_episode = 0 THEN 1
WHEN months_since_last_episode <= 3 THEN 2
ELSE 3
END,
total_episodes DESC;
```
### Speaker Schedule Report
Track speaking frequency and patterns.
```sql theme={null}
-- Speaker Schedule and Rotation Report
WITH speaker_calendar AS (
SELECT
DATE_TRUNC('month', e.published_live_at) as month,
sp.speaker_id,
sp.formatted_name as speaker_name,
COUNT(DISTINCT e.episode_id) as episodes_in_month,
STRING_AGG(
TO_CHAR(e.published_live_at, 'DD') || ': ' ||
LEFT(e.title, 30),
'; ' ORDER BY e.published_live_at
) as episode_schedule
FROM planning_center.publishing_speakers sp
JOIN planning_center.publishing_speakerships_relationships spr
ON spr.relationship_id = sp.speaker_id AND spr.relationship_type = 'speaker'
JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.relationship_id = spr.speakership_id AND ship_er.relationship_type = 'speakerships'
JOIN planning_center.publishing_episodes e ON e.episode_id = ship_er.episode_id
WHERE e.published_live_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 months')
GROUP BY month, sp.speaker_id, sp.formatted_name
),
speaker_summary AS (
SELECT
speaker_id,
speaker_name,
COUNT(DISTINCT month) as months_active,
SUM(episodes_in_month) as total_episodes,
AVG(episodes_in_month) as avg_episodes_per_active_month,
MAX(episodes_in_month) as max_episodes_in_month,
STRING_AGG(
TO_CHAR(month, 'Mon') || '(' || episodes_in_month || ')',
', ' ORDER BY month DESC
) as monthly_distribution
FROM speaker_calendar
GROUP BY speaker_id, speaker_name
)
SELECT
TO_CHAR(sc.month, 'FMMonth YYYY') as month,
sc.speaker_name,
sc.episodes_in_month as episodes,
ROUND(sc.episodes_in_month::numeric * 100.0 /
SUM(sc.episodes_in_month) OVER (PARTITION BY sc.month), 1) as pct_of_month,
ss.total_episodes as six_month_total,
ss.avg_episodes_per_active_month as avg_per_month,
sc.episode_schedule as schedule_details,
CASE
WHEN sc.episodes_in_month >= 3 THEN 'Heavy'
WHEN sc.episodes_in_month = 2 THEN 'Moderate'
ELSE 'Light'
END as workload_level
FROM speaker_calendar sc
JOIN speaker_summary ss
ON sc.speaker_id = ss.speaker_id
ORDER BY sc.month DESC, sc.episodes_in_month DESC;
```
## Channel and Distribution Reports
### Multi-Channel Performance Report
Compare content performance across distribution channels.
```sql theme={null}
-- Multi-Channel Distribution Performance Report
WITH channel_metrics AS (
SELECT
c.channel_id,
c.name as channel_name,
COUNT(DISTINCT er_ch.episode_id) as total_episodes,
COUNT(DISTINCT CASE
WHEN e.published_live_at >= CURRENT_DATE - INTERVAL '30 days'
THEN e.episode_id
END) as recent_episodes,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
COUNT(DISTINCT et.episode_time_id) as scheduled_publishes,
MIN(e.published_live_at) as first_publish,
MAX(e.published_live_at) as latest_publish
FROM planning_center.publishing_channels c
LEFT JOIN planning_center.publishing_episodes_relationships er_ch
ON er_ch.relationship_id = c.channel_id AND er_ch.relationship_type = 'channel'
LEFT JOIN planning_center.publishing_episodes e ON e.episode_id = er_ch.episode_id
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships er_et
ON er_et.episode_id = e.episode_id AND er_et.relationship_type = 'episode_times'
LEFT JOIN planning_center.publishing_episode_times et ON et.episode_time_id = er_et.relationship_id
GROUP BY c.channel_id, c.name
),
channel_rankings AS (
SELECT
*,
RANK() OVER (ORDER BY total_watches DESC NULLS LAST) as watch_rank,
CASE
WHEN latest_publish >= CURRENT_DATE - INTERVAL '7 days' THEN 'Active'
WHEN latest_publish >= CURRENT_DATE - INTERVAL '30 days' THEN 'Recent'
ELSE 'Inactive'
END as channel_status
FROM channel_metrics
)
SELECT
channel_name,
channel_status,
total_episodes,
recent_episodes as episodes_last_30_days,
TO_CHAR(first_publish, 'Mon YYYY') as active_since,
TO_CHAR(latest_publish, 'Mon DD, YYYY') as last_publish,
ROUND(avg_watches::numeric, 0) as avg_watches,
watch_rank,
total_watches,
scheduled_publishes as future_scheduled,
CASE
WHEN total_watches > 1000 THEN 'High Impact'
WHEN total_watches > 500 THEN 'Growing'
WHEN total_watches > 100 THEN 'Moderate'
ELSE 'Developing'
END as channel_tier
FROM channel_rankings
ORDER BY channel_status, total_watches DESC NULLS LAST;
```
## Engagement and Growth Reports
### Audience Growth Trajectory Report
Track audience growth over time.
```sql theme={null}
-- Audience Growth Trajectory Report
WITH monthly_audience AS (
SELECT
DATE_TRUNC('month', e.published_live_at) as month,
COUNT(DISTINCT e.episode_id) as episodes_published,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches_per_episode
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '24 months'
AND e.published_live_at < DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')
GROUP BY month
),
growth_metrics AS (
SELECT
month,
episodes_published,
total_watches,
avg_watches_per_episode,
LAG(total_watches, 1) OVER (ORDER BY month) as prev_month_watches,
LAG(total_watches, 12) OVER (ORDER BY month) as year_ago_watches,
AVG(total_watches) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) as three_month_avg,
AVG(total_watches) OVER (ORDER BY month ROWS BETWEEN 11 PRECEDING AND CURRENT ROW) as twelve_month_avg
FROM monthly_audience
)
SELECT
TO_CHAR(month, 'Mon YYYY') as month,
episodes_published,
total_watches,
ROUND(((total_watches::numeric - prev_month_watches) /
NULLIF(prev_month_watches, 0)) * 100, 1) as mom_growth_pct,
ROUND(((total_watches::numeric - year_ago_watches) /
NULLIF(year_ago_watches, 0)) * 100, 1) as yoy_growth_pct,
ROUND(avg_watches_per_episode::numeric, 0) as avg_watches_per_episode,
ROUND(three_month_avg::numeric, 0) as three_month_rolling_avg,
ROUND(twelve_month_avg::numeric, 0) as twelve_month_rolling_avg,
CASE
WHEN total_watches > prev_month_watches * 1.2 THEN 'Strong Growth'
WHEN total_watches > prev_month_watches THEN 'Moderate Growth'
WHEN total_watches > prev_month_watches * 0.9 THEN 'Stable'
ELSE 'Declining'
END as trend,
CASE
WHEN month = DATE_TRUNC('month', CURRENT_DATE) THEN '*** CURRENT MONTH ***'
ELSE ''
END as note
FROM growth_metrics
ORDER BY month DESC
LIMIT 24;
```
### Weekly Publishing Cadence Report
Analyze your publishing consistency.
```sql theme={null}
-- Weekly Publishing Cadence and Consistency Report
WITH weekly_publishing AS (
SELECT
DATE_TRUNC('week', e.published_live_at) as week,
COUNT(DISTINCT e.episode_id) as episodes_published,
COUNT(DISTINCT ser_er.relationship_id) as active_series,
COUNT(DISTINCT spr.relationship_id) as speakers_used,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as weekly_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
ARRAY_AGG(DISTINCT EXTRACT(DOW FROM e.published_live_at)::int ORDER BY EXTRACT(DOW FROM e.published_live_at)::int) as publishing_days,
STRING_AGG(e.title, '; ' ORDER BY e.published_live_at) as episode_titles
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
WHERE e.published_live_at >= CURRENT_DATE - INTERVAL '12 weeks'
GROUP BY week
),
weekly_analysis AS (
SELECT
week,
episodes_published,
active_series,
speakers_used,
weekly_watches,
avg_watches,
publishing_days,
CASE
WHEN 0 = ANY(publishing_days) THEN 'Sunday'
WHEN 1 = ANY(publishing_days) THEN 'Monday'
WHEN 2 = ANY(publishing_days) THEN 'Tuesday'
WHEN 3 = ANY(publishing_days) THEN 'Wednesday'
WHEN 4 = ANY(publishing_days) THEN 'Thursday'
WHEN 5 = ANY(publishing_days) THEN 'Friday'
WHEN 6 = ANY(publishing_days) THEN 'Saturday'
END as primary_publish_day,
ARRAY_LENGTH(publishing_days, 1) as days_with_content,
LEFT(episode_titles, 100) as episode_sample
FROM weekly_publishing
)
SELECT
TO_CHAR(week, 'Week of Mon DD, YYYY') as week,
episodes_published,
CASE
WHEN episodes_published = 0 THEN '❌ No Content'
WHEN episodes_published = 1 THEN '✅ Standard'
WHEN episodes_published = 2 THEN '⭐ Above Average'
ELSE '🔥 High Output'
END as publishing_level,
active_series,
speakers_used,
weekly_watches,
ROUND(avg_watches::numeric, 0) as avg_watches,
days_with_content,
primary_publish_day,
episode_sample
FROM weekly_analysis
ORDER BY week DESC;
```
## Export and Automation Tips
### Scheduling These Reports
1. **Daily Reports**: Executive Dashboard (for high-activity churches)
2. **Weekly Reports**: Publishing Cadence, Speaker Schedule
3. **Monthly Reports**: Monthly Executive Summary, Series Performance
4. **Quarterly Reports**: Quarterly Performance, Biblical Coverage
### Export Formats
* **CSV**: Best for Excel analysis and pivot tables
* **PDF**: Best for board presentations
* **JSON**: Best for dashboard integrations
* **HTML**: Best for email reports
### Automation Ideas
Your Parable database connection is **read-only**. You cannot create views,
materialized views, or indexes through it. Save this query in your BI tool
(as a dataset or extract) or as a Parable report instead.
```sql theme={null}
-- Weekly publishing summary — save as a scheduled report or BI dataset
SELECT
DATE_TRUNC('week', CURRENT_DATE) as report_week,
COUNT(DISTINCT e.episode_id) as episodes_this_week,
SUM(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as total_watches,
AVG(COALESCE(est.library_watch_count, 0) + COALESCE(est.live_watch_count, 0)) as avg_watches,
COUNT(DISTINCT spr.relationship_id) as speakers,
COUNT(DISTINCT ser_er.relationship_id) as series
FROM planning_center.publishing_episodes e
LEFT JOIN planning_center.publishing_episode_statistics est
ON est.episode_statistics_id = 'es-' || e.episode_id
LEFT JOIN planning_center.publishing_episodes_relationships ser_er
ON ser_er.episode_id = e.episode_id AND ser_er.relationship_type = 'series'
LEFT JOIN planning_center.publishing_episodes_relationships ship_er
ON ship_er.episode_id = e.episode_id AND ship_er.relationship_type = 'speakerships'
LEFT JOIN planning_center.publishing_speakerships_relationships spr
ON spr.speakership_id = ship_er.relationship_id AND spr.relationship_type = 'speaker'
WHERE e.published_live_at >= DATE_TRUNC('week', CURRENT_DATE)
AND e.published_live_at < DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '1 week';
```
## Next Steps
* Customize these reports with your church's specific KPIs
* Add filters for specific campuses or ministries
* Create dashboards using these queries as data sources
* Set up alerts for unusual patterns or milestones
***
*Transform your publishing data into actionable insights. These reports help you make data-driven decisions about your content strategy.*
# Advanced Planning Center Registrations Queries
Source: https://docs.getparable.io/planning-center/registrations/advanced-queries
Advanced Registrations SQL using CTEs and window functions to analyze signup velocity, waitlist movement, and overall event performance.
## Complex Analytics for Strategic Event Management
Take your event analysis to the next level with CTEs, window functions, and cross-module integration. These queries provide deep insights for strategic planning and optimization.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Registrations module live in the `planning_center` schema. Always prefix table names with `planning_center.` in advanced queries.
✅ CORRECT: `SELECT * FROM planning_center.registrations_attendees`
❌ INCORRECT: `SELECT * FROM registrations_attendees`
### Row Level Security (RLS)
Row Level Security automatically filters results by:
* **tenant\_organization\_id** – limits data to your organization
* **system\_status** – active records returned by default
**Skip manual filters for these columns**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus on event-specific logic (archived flags, status transitions, waitlists) while RLS manages tenancy and system status.
## Registration Trends and Patterns
### Registration Velocity Analysis
```sql theme={null}
-- Track registration speed and predict fill rates
WITH registration_velocity AS (
SELECT
s.signup_id,
s.name as event_name,
st.starts_at as event_date,
a.created_at as registration_date,
DATE_PART('day', st.starts_at - a.created_at) as days_before_event,
COUNT(*) OVER (
PARTITION BY s.signup_id
ORDER BY a.created_at
ROWS UNBOUNDED PRECEDING
) as cumulative_registrations,
ROW_NUMBER() OVER (
PARTITION BY s.signup_id
ORDER BY a.created_at
) as registration_order
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_signups_relationships sr
ON sr.signup_id = s.signup_id
AND sr.relationship_type IN ('SignupTime', 'signup_time')
JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr.relationship_id
JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
AND a.active = true
WHERE s.archived = false
),
velocity_stats AS (
SELECT
event_name,
event_date,
MAX(cumulative_registrations) as total_registrations,
AVG(days_before_event) as avg_days_before,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY days_before_event) as median_days_before,
COUNT(CASE WHEN days_before_event >= 30 THEN 1 END) as early_birds,
COUNT(CASE WHEN days_before_event < 7 THEN 1 END) as last_minute
FROM registration_velocity
GROUP BY event_name, event_date
)
SELECT
event_name,
event_date,
total_registrations,
ROUND(avg_days_before::numeric, 1) as avg_registration_lead_time,
ROUND(median_days_before::numeric, 1) as median_registration_lead_time,
ROUND(early_birds * 100.0 / total_registrations, 1) as early_bird_percentage,
ROUND(last_minute * 100.0 / total_registrations, 1) as last_minute_percentage
FROM velocity_stats
ORDER BY event_date DESC;
```
### Year-over-Year Event Comparison
```sql theme={null}
-- Compare event performance across years
WITH yearly_events AS (
SELECT
s.name as event_name,
DATE_PART('year', st.starts_at) as event_year,
DATE_PART('month', st.starts_at) as event_month,
COUNT(DISTINCT a.attendee_id) as total_attendees,
COUNT(CASE WHEN a.active = true THEN 1 END) as active_registrations,
COUNT(CASE WHEN a.canceled = true THEN 1 END) as cancellations,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlisted
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_signups_relationships sr
ON sr.signup_id = s.signup_id
AND sr.relationship_type IN ('SignupTime', 'signup_time')
JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr.relationship_id
LEFT JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
LEFT JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
GROUP BY s.name, DATE_PART('year', st.starts_at), DATE_PART('month', st.starts_at)
),
year_comparison AS (
SELECT
event_name,
event_month,
MAX(CASE WHEN event_year = DATE_PART('year', CURRENT_DATE) - 1
THEN total_attendees END) as last_year,
MAX(CASE WHEN event_year = DATE_PART('year', CURRENT_DATE)
THEN total_attendees END) as this_year,
MAX(CASE WHEN event_year = DATE_PART('year', CURRENT_DATE) - 1
THEN cancellations END) as cancellations_last_year,
MAX(CASE WHEN event_year = DATE_PART('year', CURRENT_DATE)
THEN cancellations END) as cancellations_this_year
FROM yearly_events
GROUP BY event_name, event_month
)
SELECT
event_name,
TO_CHAR(TO_DATE(event_month::text, 'MM'), 'FMMonth') as month,
COALESCE(last_year, 0) as last_year_attendees,
COALESCE(this_year, 0) as this_year_attendees,
CASE
WHEN last_year > 0
THEN ROUND((this_year - last_year) * 100.0 / last_year, 1)
ELSE NULL
END as growth_percentage,
COALESCE(cancellations_last_year, 0) as cancellations_last_year,
COALESCE(cancellations_this_year, 0) as cancellations_this_year
FROM year_comparison
WHERE last_year IS NOT NULL OR this_year IS NOT NULL
ORDER BY event_month, event_name;
```
## Waitlist Analytics
### Waitlist Conversion Funnel
```sql theme={null}
-- Analyze waitlist to registration conversions
WITH waitlist_timeline AS (
SELECT
a.attendee_id,
s.signup_id,
s.name as event_name,
a.waitlisted_at,
a.created_at as registration_date,
a.waitlisted,
a.active,
a.canceled,
LEAD(a.waitlisted) OVER (
PARTITION BY a.attendee_id
ORDER BY a.updated_at
) as next_waitlist_status,
LEAD(a.active) OVER (
PARTITION BY a.attendee_id
ORDER BY a.updated_at
) as next_active_status
FROM planning_center.registrations_attendees a
JOIN planning_center.registrations_attendees_relationships ar
ON ar.attendee_id = a.attendee_id
AND ar.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.registration_id = ar.relationship_id
AND ar_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_sg.relationship_id
WHERE a.waitlisted_at IS NOT NULL
),
conversion_metrics AS (
SELECT
event_name,
COUNT(DISTINCT attendee_id) as total_waitlisted,
COUNT(DISTINCT CASE
WHEN active = true
THEN attendee_id
END) as converted_to_active,
COUNT(DISTINCT CASE
WHEN canceled = true
THEN attendee_id
END) as canceled_from_waitlist,
AVG(CASE
WHEN active = true
THEN EXTRACT(EPOCH FROM (registration_date - waitlisted_at))/3600
END) as avg_hours_to_conversion
FROM waitlist_timeline
GROUP BY event_name
)
SELECT
event_name,
total_waitlisted,
converted_to_active,
ROUND(converted_to_active * 100.0 / NULLIF(total_waitlisted, 0), 1) as conversion_rate,
canceled_from_waitlist,
ROUND(canceled_from_waitlist * 100.0 / NULLIF(total_waitlisted, 0), 1) as cancellation_rate,
ROUND(avg_hours_to_conversion / 24, 1) as avg_days_to_conversion
FROM conversion_metrics
WHERE total_waitlisted > 0
ORDER BY conversion_rate DESC;
```
## Geographic Analysis
### Registration Heatmap Data
```sql theme={null}
-- Geographic distribution of registrations
WITH location_registrations AS (
SELECT
sl.latitude,
sl.longitude,
sl.name as location_name,
sl.formatted_address,
s.name as event_name,
COUNT(DISTINCT a.attendee_id) as registration_count
FROM planning_center.registrations_signup_locations sl
JOIN planning_center.registrations_signups_relationships sr
ON sr.relationship_id = sl.signup_location_id
AND sr.relationship_type IN ('SignupLocation', 'signup_location')
JOIN planning_center.registrations_signups s
ON s.signup_id = sr.signup_id
LEFT JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
LEFT JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
AND a.active = true
WHERE sl.latitude IS NOT NULL
AND sl.longitude IS NOT NULL
AND s.archived = false
GROUP BY sl.latitude, sl.longitude, sl.name, sl.formatted_address, s.name
),
location_stats AS (
SELECT
latitude,
longitude,
location_name,
formatted_address,
COUNT(DISTINCT event_name) as events_at_location,
SUM(registration_count) as total_registrations,
AVG(registration_count) as avg_registrations_per_event,
STRING_AGG(event_name || ' (' || registration_count || ')', ', '
ORDER BY registration_count DESC) as event_details
FROM location_registrations
GROUP BY latitude, longitude, location_name, formatted_address
)
SELECT
location_name,
formatted_address,
latitude,
longitude,
events_at_location,
total_registrations,
ROUND(avg_registrations_per_event, 1) as avg_registrations,
event_details
FROM location_stats
ORDER BY total_registrations DESC;
```
### Campus Performance Comparison
```sql theme={null}
-- Comprehensive campus metrics
WITH campus_events AS (
SELECT
c.campus_id,
c.name as campus_name,
s.signup_id,
s.name as event_name,
st.starts_at as event_date,
COUNT(DISTINCT a.attendee_id) as total_attendees,
COUNT(CASE WHEN a.active = true THEN 1 END) as active_registrations,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlisted
FROM planning_center.registrations_campuses c
JOIN planning_center.registrations_signups_relationships sr_campus
ON sr_campus.relationship_id = c.campus_id
AND sr_campus.relationship_type IN ('Campus', 'campus')
JOIN planning_center.registrations_signups s
ON s.signup_id = sr_campus.signup_id
LEFT JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
LEFT JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
LEFT JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
WHERE s.archived = false
GROUP BY c.campus_id, c.name, s.signup_id, s.name, st.starts_at
),
campus_summary AS (
SELECT
campus_name,
COUNT(DISTINCT signup_id) as total_events,
SUM(total_attendees) as total_registrations,
AVG(total_attendees) as avg_attendees_per_event,
SUM(waitlisted) as total_waitlisted,
MIN(event_date) as first_event,
MAX(event_date) as last_event
FROM campus_events
GROUP BY campus_name
)
SELECT
campus_name,
total_events,
total_registrations,
ROUND(avg_attendees_per_event, 1) as avg_attendees,
total_waitlisted,
ROUND(total_waitlisted * 100.0 / NULLIF(total_registrations, 0), 1) as waitlist_percentage,
first_event::date as first_event_date,
last_event::date as last_event_date,
DATE_PART('day', last_event - first_event) as days_of_activity
FROM campus_summary
ORDER BY total_registrations DESC;
```
## Demand Analysis
Registrations carries no money. `registrations_selection_types` syncs
identifiers only (empty `name`, `price_cents = 0`), and its rows cannot be
joined to attendees — so revenue, ticket price, and price-elasticity reporting
are not possible from this module. Use the Giving module for money received.
The queries below analyse **demand** instead, which the data does support.
### Category Performance by Volume
```sql theme={null}
-- Which categories of events actually draw registrations
WITH signup_categories AS (
SELECT signup_id, relationship_id as category_id
FROM planning_center.registrations_signups_relationships
WHERE relationship_type IN ('Category', 'category')
),
attendee_counts AS (
SELECT
rr.relationship_id as signup_id,
COUNT(DISTINCT a.attendee_id) FILTER (WHERE a.active = true) as active_attendees,
COUNT(DISTINCT a.attendee_id) FILTER (WHERE a.waitlisted = true) as waitlisted,
COUNT(DISTINCT a.attendee_id) FILTER (WHERE a.canceled = true) as canceled
FROM planning_center.registrations_registrations_relationships rr
JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_id = rr.registration_id
AND ar.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
WHERE rr.relationship_type IN ('Signup', 'signup')
GROUP BY rr.relationship_id
)
SELECT
cat.name as category_name,
COUNT(DISTINCT s.signup_id) as events,
COALESCE(SUM(ac.active_attendees), 0) as total_registered,
COALESCE(SUM(ac.waitlisted), 0) as total_waitlisted,
COALESCE(SUM(ac.canceled), 0) as total_canceled,
ROUND(
COALESCE(SUM(ac.active_attendees), 0)::NUMERIC
/ NULLIF(COUNT(DISTINCT s.signup_id), 0), 1
) as avg_registrations_per_event
FROM planning_center.registrations_categories cat
JOIN signup_categories sc ON sc.category_id = cat.category_id
JOIN planning_center.registrations_signups s ON s.signup_id = sc.signup_id
LEFT JOIN attendee_counts ac ON ac.signup_id = s.signup_id
GROUP BY cat.name
ORDER BY total_registered DESC;
```
### How Early People Register
```sql theme={null}
-- Lead time between registering and the event starting.
-- A negative average means registrations kept arriving after the start date,
-- which is normal for rolling or ongoing signups.
WITH event_start AS (
SELECT
sr.signup_id,
MIN(st.starts_at) as event_start
FROM planning_center.registrations_signups_relationships sr
JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr.relationship_id
WHERE sr.relationship_type IN ('SignupTime', 'signup_time')
GROUP BY sr.signup_id
)
SELECT
s.name as event_name,
es.event_start,
COUNT(DISTINCT a.attendee_id) FILTER (WHERE a.active = true) as registered,
COUNT(DISTINCT a.attendee_id) FILTER (WHERE a.waitlisted = true) as waitlisted,
ROUND(AVG(DATE_PART('day', es.event_start - a.created_at))::NUMERIC, 1) as avg_days_booked_ahead
FROM planning_center.registrations_signups s
JOIN event_start es ON es.signup_id = s.signup_id
JOIN planning_center.registrations_registrations_relationships rr
ON rr.relationship_id = s.signup_id
AND rr.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_id = rr.registration_id
AND ar.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
WHERE s.archived = false
GROUP BY s.name, es.event_start
ORDER BY registered DESC;
```
## Cross-Module Integration
### Registrations with People Data
```sql theme={null}
-- Combine registrations with People module demographics
WITH person_registrations AS (
SELECT
rp.person_id,
rp.name as registrant_name,
pp.birthdate,
pp.gender,
pp.membership,
pp.status as person_status,
COUNT(DISTINCT s.signup_id) as events_registered,
SUM(CASE WHEN a.active = true THEN 1 ELSE 0 END) as active_registrations,
SUM(CASE WHEN a.waitlisted = true THEN 1 ELSE 0 END) as waitlist_registrations
FROM planning_center.registrations_people rp
LEFT JOIN planning_center.people_people pp
ON pp.person_id = rp.person_id
LEFT JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.relationship_id = rp.person_id
AND ar_person.relationship_type IN ('Person', 'person')
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar_person.attendee_id
LEFT JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.attendee_id = a.attendee_id
AND ar_signup.relationship_type IN ('Registration', 'registration')
LEFT JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.registration_id = ar_signup.relationship_id
AND ar_signup_sg.relationship_type IN ('Signup', 'signup')
LEFT JOIN planning_center.registrations_signups s
ON s.signup_id = ar_signup_sg.relationship_id
GROUP BY rp.person_id, rp.name, pp.birthdate, pp.gender, pp.membership, pp.status
),
demographic_summary AS (
SELECT
CASE
WHEN birthdate IS NULL THEN 'Unknown'
WHEN DATE_PART('year', AGE(birthdate)) < 18 THEN 'Youth'
WHEN DATE_PART('year', AGE(birthdate)) < 30 THEN 'Young Adult'
WHEN DATE_PART('year', AGE(birthdate)) < 50 THEN 'Adult'
ELSE 'Senior'
END as age_group,
COALESCE(gender, 'Not Specified') as gender,
COALESCE(membership, 'Non-Member') as membership_status,
COUNT(DISTINCT person_id) as unique_registrants,
SUM(events_registered) as total_event_registrations,
AVG(events_registered) as avg_events_per_person,
SUM(active_registrations) as total_active,
SUM(waitlist_registrations) as total_waitlisted
FROM person_registrations
WHERE person_status = 'active'
GROUP BY age_group, gender, membership_status
)
SELECT
age_group,
gender,
membership_status,
unique_registrants,
total_event_registrations,
ROUND(avg_events_per_person, 1) as avg_events_per_person,
total_active,
total_waitlisted,
ROUND(total_waitlisted * 100.0 / NULLIF(total_active + total_waitlisted, 0), 1) as waitlist_percentage
FROM demographic_summary
ORDER BY unique_registrants DESC;
```
### Registration Impact on Giving
```sql theme={null}
-- Analyze giving patterns of event attendees
WITH event_attendees AS (
SELECT DISTINCT
rp.person_id,
rp.name,
s.name as event_name,
cat.name as event_category,
st.starts_at as event_date
FROM planning_center.registrations_people rp
JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.relationship_id = rp.person_id
AND ar_person.relationship_type IN ('Person', 'person')
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar_person.attendee_id
AND a.active = true
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.attendee_id = a.attendee_id
AND ar_signup.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.registration_id = ar_signup.relationship_id
AND ar_signup_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_signup_sg.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_cat
ON sr_cat.signup_id = s.signup_id
AND sr_cat.relationship_type IN ('Category', 'category')
LEFT JOIN planning_center.registrations_categories cat
ON cat.category_id = sr_cat.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
),
giving_analysis AS (
SELECT
ea.event_category,
COUNT(DISTINCT ea.person_id) as attendee_count,
COUNT(DISTINCT gp.person_id) as donors_count,
COUNT(DISTINCT CASE
WHEN d.received_at > ea.event_date
THEN gp.person_id
END) as donors_after_event,
SUM(CASE
WHEN d.received_at > ea.event_date
THEN d.amount_cents / 100.0
END) as giving_after_event
FROM event_attendees ea
LEFT JOIN planning_center.giving_people gp
ON gp.person_id = ea.person_id
LEFT JOIN planning_center.giving_donations_relationships dr
ON dr.relationship_id = gp.person_id AND dr.relationship_type IN ('Person', 'person')
LEFT JOIN planning_center.giving_donations d
ON d.donation_id = dr.donation_id
GROUP BY ea.event_category
)
SELECT
COALESCE(event_category, 'Uncategorized') as category,
attendee_count,
donors_count,
ROUND(donors_count * 100.0 / NULLIF(attendee_count, 0), 1) as donor_percentage,
donors_after_event,
ROUND(donors_after_event * 100.0 / NULLIF(attendee_count, 0), 1) as new_donor_percentage,
COALESCE(ROUND(giving_after_event, 2), 0) as total_giving_after_events
FROM giving_analysis
WHERE attendee_count > 0
ORDER BY attendee_count DESC;
```
## Performance Optimization Patterns
### Indexed Subquery Pattern
```sql theme={null}
-- Efficient pattern for large datasets using indexed subqueries
WITH indexed_signups AS (
SELECT
signup_id,
name,
archived
FROM planning_center.registrations_signups
WHERE archived = false
AND created_at >= CURRENT_DATE - INTERVAL '1 year'
),
indexed_attendees AS (
SELECT
ar.relationship_id as signup_id,
COUNT(*) as attendee_count,
COUNT(CASE WHEN a.active = true THEN 1 END) as active_count,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlist_count
FROM planning_center.registrations_attendees a
JOIN planning_center.registrations_attendees_relationships ar
ON ar.attendee_id = a.attendee_id
AND ar.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.registration_id = ar.relationship_id
AND ar_sg.relationship_type IN ('Signup', 'signup')
WHERE a.created_at >= CURRENT_DATE - INTERVAL '1 year'
GROUP BY ar.relationship_id
)
SELECT
s.name,
COALESCE(a.attendee_count, 0) as total_attendees,
COALESCE(a.active_count, 0) as active_registrations,
COALESCE(a.waitlist_count, 0) as waitlisted,
CASE
WHEN a.waitlist_count > 0
THEN ROUND(a.waitlist_count * 100.0 / a.attendee_count, 1)
ELSE 0
END as waitlist_percentage
FROM indexed_signups s
LEFT JOIN indexed_attendees a
ON a.signup_id = s.signup_id
ORDER BY a.attendee_count DESC NULLS LAST;
```
### Event Metrics Rollup
Your Parable database connection is **read-only**. You cannot create
materialized views or indexes through it. Run this query directly, schedule it
as a Parable report, or let your BI tool cache the result set.
```sql theme={null}
-- Frequently accessed event metrics — schedule as a report or BI dataset
WITH event_metrics AS (
SELECT
s.signup_id,
s.name as event_name,
s.archived,
s.open_at,
s.close_at,
cat.name as category,
camp.name as campus,
loc.name as location,
tim.starts_at as event_date,
COUNT(DISTINCT a.attendee_id) as total_attendees,
COUNT(CASE WHEN a.active = true THEN 1 END) as active_registrations,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlisted,
COUNT(CASE WHEN a.canceled = true THEN 1 END) as canceled
FROM planning_center.registrations_signups s
-- All the necessary joins...
LEFT JOIN planning_center.registrations_signups_relationships sr_cat
ON sr_cat.signup_id = s.signup_id AND sr_cat.relationship_type IN ('Category', 'category')
LEFT JOIN planning_center.registrations_categories cat
ON cat.category_id = sr_cat.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_camp
ON sr_camp.signup_id = s.signup_id AND sr_camp.relationship_type IN ('Campus', 'campus')
LEFT JOIN planning_center.registrations_campuses camp
ON camp.campus_id = sr_camp.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_loc
ON sr_loc.signup_id = s.signup_id AND sr_loc.relationship_type IN ('SignupLocation', 'signup_location')
LEFT JOIN planning_center.registrations_signup_locations loc
ON loc.signup_location_id = sr_loc.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_tim
ON sr_tim.signup_id = s.signup_id AND sr_tim.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times tim
ON tim.signup_time_id = sr_tim.relationship_id
LEFT JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup') AND ar_sg.relationship_id = s.signup_id
LEFT JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration') AND ar.relationship_id = ar_sg.registration_id
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
GROUP BY s.signup_id, s.name, s.archived, s.open_at, s.close_at,
cat.name, camp.name, loc.name, tim.starts_at
)
SELECT *
FROM event_metrics
WHERE archived = false
AND event_date >= CURRENT_DATE
ORDER BY event_date;
```
## Tips for Advanced Queries
1. **Use CTEs liberally** - They make complex queries readable and maintainable
2. **Index awareness** - Structure WHERE clauses to use existing indexes
3. **Window functions** - Great for running totals, rankings, and comparisons
4. **COALESCE for NULLs** - Handle missing data gracefully
5. **Cross-module carefully** - Join to other modules only when necessary
6. **Test with EXPLAIN** - Analyze query plans for performance bottlenecks
## Next Steps
Ready to build production reports? Check out our [Reporting Examples](/planning-center/registrations/reporting-examples) for complete, ready-to-use report templates.
# Basic Planning Center Registrations Queries
Source: https://docs.getparable.io/planning-center/registrations/basic-queries
Practical SQL for Planning Center Registrations: list signups by event, count attendees, and filter registrations by status or date.
## Simple, Practical SQL for Event Management
Start here to learn the fundamentals of querying Planning Center Registrations data. These examples cover common scenarios you'll encounter in day-to-day event management.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Registrations module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.registrations_signups`
❌ INCORRECT: `SELECT * FROM registrations_signups`
### Row Level Security (RLS)
Row Level Security automatically filters results by:
* **tenant\_organization\_id** – limits data to your organization
* **system\_status** – returns active records by default
**Skip manual filters for these columns**—RLS already enforces them and redundant predicates can mask data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Keep your focus on event-specific filters (archived flags, dates, statuses) while RLS handles tenancy and system status automatically.
## Finding Events
### List All Active Events
```sql theme={null}
-- Get all non-archived events
SELECT
signup_id,
name,
description,
open_at,
close_at,
created_at
FROM planning_center.registrations_signups
WHERE archived = false
ORDER BY created_at DESC;
```
### Find Upcoming Events
```sql theme={null}
-- Events with future dates
SELECT
s.name as event_name,
st.starts_at as event_date,
st.ends_at,
st.all_day
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_signups_relationships sr
ON sr.signup_id = s.signup_id
AND sr.relationship_type IN ('SignupTime', 'signup_time')
JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr.relationship_id
WHERE s.archived = false
AND st.starts_at >= CURRENT_DATE
ORDER BY st.starts_at;
```
### Events by Category
```sql theme={null}
-- Find events in specific categories
SELECT
s.name as event_name,
c.name as category_name
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_signups_relationships sr
ON sr.signup_id = s.signup_id
AND sr.relationship_type IN ('Category', 'category')
JOIN planning_center.registrations_categories c
ON c.category_id = sr.relationship_id
WHERE s.archived = false
ORDER BY c.name, s.name;
```
## Registration Counts
### Count Attendees per Event
```sql theme={null}
-- Basic registration counts
SELECT
s.name as event_name,
COUNT(DISTINCT a.attendee_id) as total_attendees,
COUNT(CASE WHEN a.active = true THEN 1 END) as active_registrations,
COUNT(CASE WHEN a.canceled = true THEN 1 END) as canceled_registrations,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlisted
FROM planning_center.registrations_signups s
LEFT JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
LEFT JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
WHERE s.archived = false
GROUP BY s.name
ORDER BY total_attendees DESC;
```
### Recent Registrations
```sql theme={null}
-- Last 20 registrations across all events
SELECT
p.name as attendee_name,
s.name as event_name,
a.created_at as registration_date,
CASE
WHEN a.waitlisted = true THEN 'Waitlisted'
WHEN a.canceled = true THEN 'Canceled'
WHEN a.active = true THEN 'Active'
ELSE 'Unknown'
END as status
FROM planning_center.registrations_attendees a
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.attendee_id = a.attendee_id
AND ar_signup.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.registration_id = ar_signup.relationship_id
AND ar_signup_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_signup_sg.relationship_id
JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.attendee_id = a.attendee_id
AND ar_person.relationship_type IN ('Person', 'person')
JOIN planning_center.registrations_people p
ON p.person_id = ar_person.relationship_id
ORDER BY a.created_at DESC
LIMIT 20;
```
## Waitlist Management
### View Waitlisted Attendees
```sql theme={null}
-- All waitlisted attendees with their wait times
SELECT
p.name as attendee_name,
s.name as event_name,
a.waitlisted_at,
CURRENT_TIMESTAMP - a.waitlisted_at as time_on_waitlist
FROM planning_center.registrations_attendees a
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.attendee_id = a.attendee_id
AND ar_signup.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.registration_id = ar_signup.relationship_id
AND ar_signup_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_signup_sg.relationship_id
JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.attendee_id = a.attendee_id
AND ar_person.relationship_type IN ('Person', 'person')
JOIN planning_center.registrations_people p
ON p.person_id = ar_person.relationship_id
WHERE a.waitlisted = true
ORDER BY s.name, a.waitlisted_at;
```
### Events with Waitlists
```sql theme={null}
-- Find events that have waitlists
SELECT
s.name as event_name,
COUNT(CASE WHEN a.active = true THEN 1 END) as active_count,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlist_count
FROM planning_center.registrations_signups s
LEFT JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
LEFT JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
WHERE s.archived = false
GROUP BY s.name
HAVING COUNT(CASE WHEN a.waitlisted = true THEN 1 END) > 0
ORDER BY waitlist_count DESC;
```
## Location Information
### Events by Location
```sql theme={null}
-- List events with their venues
SELECT
s.name as event_name,
sl.name as location_name,
sl.formatted_address,
sl.latitude,
sl.longitude
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_signups_relationships sr
ON sr.signup_id = s.signup_id
AND sr.relationship_type IN ('SignupLocation', 'signup_location')
JOIN planning_center.registrations_signup_locations sl
ON sl.signup_location_id = sr.relationship_id
WHERE s.archived = false
ORDER BY sl.name, s.name;
```
### Campus Events
```sql theme={null}
-- Events by campus
SELECT
c.name as campus_name,
s.name as event_name,
st.starts_at as event_date
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_signups_relationships sr_campus
ON sr_campus.signup_id = s.signup_id
AND sr_campus.relationship_type IN ('Campus', 'campus')
JOIN planning_center.registrations_campuses c
ON c.campus_id = sr_campus.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
WHERE s.archived = false
ORDER BY c.name, st.starts_at;
```
## Pricing and Selection Types
**Registrations pricing data is not available.** Two independent gaps make
ticket-price reporting impossible from this module:
1. `registrations_selection_types` syncs identifiers only — across production
every row has an empty `name`, `price_cents = 0`, and
`publicly_available = false`.
2. The `SelectionType` rows in `registrations_attendees_relationships` point at
per-attendee selection objects that are not part of the synced schema, so
there is no join back to a selection type record.
Report on **registration volume** instead, and use the Giving module for money
actually received.
### Registration Volume by Event
```sql theme={null}
-- How many people actually registered for each event
SELECT
s.name as event_name,
COUNT(DISTINCT a.attendee_id) FILTER (WHERE a.active = true) as active_attendees,
COUNT(DISTINCT a.attendee_id) FILTER (WHERE a.waitlisted = true) as waitlisted,
COUNT(DISTINCT a.attendee_id) FILTER (WHERE a.canceled = true) as canceled
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_registrations_relationships rr
ON rr.relationship_id = s.signup_id
AND rr.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_id = rr.registration_id
AND ar.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
WHERE s.archived = false
GROUP BY s.name
ORDER BY active_attendees DESC;
```
## Emergency Contacts
### List Emergency Contacts for Event
```sql theme={null}
-- Get emergency contacts for active attendees
SELECT
p.name as attendee_name,
s.name as event_name,
ec.name as emergency_contact_name,
ec.phone_number as emergency_phone
FROM planning_center.registrations_attendees a
-- Link to signup
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.attendee_id = a.attendee_id
AND ar_signup.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.registration_id = ar_signup.relationship_id
AND ar_signup_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_signup_sg.relationship_id
-- Link to person
JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.attendee_id = a.attendee_id
AND ar_person.relationship_type IN ('Person', 'person')
JOIN planning_center.registrations_people p
ON p.person_id = ar_person.relationship_id
-- Link to emergency contact
LEFT JOIN planning_center.registrations_attendees_relationships ar_ec
ON ar_ec.attendee_id = a.attendee_id
AND ar_ec.relationship_type IN ('EmergencyContact', 'emergency_contact')
LEFT JOIN planning_center.registrations_emergency_contacts ec
ON ec.emergency_contact_id = ar_ec.relationship_id
WHERE a.active = true
AND s.archived = false
ORDER BY s.name, p.name;
```
### Check Emergency Contact Coverage
```sql theme={null}
-- Find attendees without emergency contacts
SELECT
s.name as event_name,
COUNT(DISTINCT a.attendee_id) as total_attendees,
COUNT(DISTINCT ar_ec.attendee_id) as with_emergency_contact,
COUNT(DISTINCT a.attendee_id) - COUNT(DISTINCT ar_ec.attendee_id) as missing_emergency_contact
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.relationship_type IN ('Signup', 'signup')
AND ar_signup_sg.relationship_id = s.signup_id
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.relationship_type IN ('Registration', 'registration')
AND ar_signup.relationship_id = ar_signup_sg.registration_id
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar_signup.attendee_id
AND a.active = true
LEFT JOIN planning_center.registrations_attendees_relationships ar_ec
ON ar_ec.attendee_id = a.attendee_id
AND ar_ec.relationship_type IN ('EmergencyContact', 'emergency_contact')
WHERE s.archived = false
GROUP BY s.name
ORDER BY missing_emergency_contact DESC;
```
## Date and Time Queries
### Events This Month
```sql theme={null}
-- All events happening this month
SELECT
s.name as event_name,
st.starts_at,
st.ends_at,
st.all_day
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_signups_relationships sr
ON sr.signup_id = s.signup_id
AND sr.relationship_type IN ('SignupTime', 'signup_time')
JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr.relationship_id
WHERE s.archived = false
AND DATE_PART('month', st.starts_at) = DATE_PART('month', CURRENT_DATE)
AND DATE_PART('year', st.starts_at) = DATE_PART('year', CURRENT_DATE)
ORDER BY st.starts_at;
```
### Registration Windows
```sql theme={null}
-- Events with open/closed registration periods
SELECT
name as event_name,
open_at,
close_at,
CASE
WHEN CURRENT_TIMESTAMP < open_at THEN 'Not Yet Open'
WHEN CURRENT_TIMESTAMP BETWEEN open_at AND close_at THEN 'Open'
WHEN CURRENT_TIMESTAMP > close_at THEN 'Closed'
ELSE 'Always Open'
END as registration_status
FROM planning_center.registrations_signups
WHERE archived = false
ORDER BY
CASE
WHEN CURRENT_TIMESTAMP BETWEEN open_at AND close_at THEN 1
WHEN CURRENT_TIMESTAMP < open_at THEN 2
ELSE 3
END,
open_at;
```
## People and Attendees
### Find Person's Registrations
```sql theme={null}
-- All events a person is registered for
SELECT
p.name as person_name,
s.name as event_name,
a.created_at as registered_at,
CASE
WHEN a.waitlisted = true THEN 'Waitlisted'
WHEN a.canceled = true THEN 'Canceled'
WHEN a.active = true THEN 'Active'
ELSE 'Unknown'
END as status
FROM planning_center.registrations_people p
JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.relationship_id = p.person_id
AND ar_person.relationship_type IN ('Person', 'person')
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar_person.attendee_id
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.attendee_id = a.attendee_id
AND ar_signup.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.registration_id = ar_signup.relationship_id
AND ar_signup_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_signup_sg.relationship_id
WHERE p.name ILIKE '%smith%' -- Search by name
ORDER BY a.created_at DESC;
```
### Most Active Participants
```sql theme={null}
-- People registered for the most events
SELECT
p.name as person_name,
COUNT(DISTINCT s.signup_id) as events_registered,
COUNT(CASE WHEN a.active = true THEN 1 END) as active_registrations
FROM planning_center.registrations_people p
JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.relationship_id = p.person_id
AND ar_person.relationship_type IN ('Person', 'person')
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar_person.attendee_id
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.attendee_id = a.attendee_id
AND ar_signup.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.registration_id = ar_signup.relationship_id
AND ar_signup_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_signup_sg.relationship_id
GROUP BY p.name
HAVING COUNT(DISTINCT s.signup_id) > 1
ORDER BY events_registered DESC;
```
## Tips for Basic Queries
1. **Start simple** - Begin with single table queries and gradually add joins
2. **Use DISTINCT carefully** - Only when you need unique values
3. **Filter early** - Add WHERE clauses before GROUP BY for better performance
4. **Test with LIMIT** - Add LIMIT 10 when testing queries on large datasets
5. **Check for NULLs** - Many fields can be NULL, use COALESCE or IS NULL checks
6. **Understand relationships** - Always join through the relationship tables
## Next Steps
Ready for more complex analysis? Check out our [Advanced Queries](/planning-center/registrations/advanced-queries) guide for CTEs, window functions, and cross-module integration.
# Planning Center Registrations Data Model
Source: https://docs.getparable.io/planning-center/registrations/data-model
Complete reference for the 11 Planning Center Registrations entity tables in Parable: events, attendees, waitlists, emergency contacts, and more.
## Overview
The Registrations module contains **11 entity tables** and **3 relationship tables** supporting event registration, attendee management, emergency contacts, and signup tracking.
## Complete Schema Reference
This guide provides a comprehensive reference for all Planning Center Registrations tables available in Parable. Understanding this data model will help you write accurate queries and build meaningful reports for your event management needs.
## Visual Data Model
The diagram below shows the core entities and their relationships in the Registrations module. Use it as a visual reference while exploring the detailed table definitions below.
### Core Entity Relationships
[Open diagram in new tab →](/diagrams/planning-center/registrations-data-model-01.svg)
### Key Relationships Explained
**Registration Structure:**
* `REGISTRATION` defines an event or program accepting signups
* `CATEGORY` organizes signup options (e.g., T-shirt size, Meal preference)
* `SELECTION_TYPE` provides specific choices within categories
**Signup Flow:**
1. Person creates a `SIGNUP` for a registration
2. `ATTENDEE`(s) are added (self or others)
3. `SIGNUP_TIME`(s) specify when attendees will participate
4. `SIGNUP_LOCATION`(s) define where events occur
5. `EMERGENCY_CONTACT`(s) provide safety information
**Person vs Attendee:**
* `PERSON` is the signup submitter (from People module)
* `ATTENDEE` can be the submitter or someone else (child, guest, etc.)
* One signup can include multiple attendees
* Each attendee has their own emergency contacts
**Time and Location:**
* `SIGNUP_TIME` tracks session/workshop times
* `SIGNUP_LOCATION` tracks event venues
* Both linked to parent signup, not individual attendees
* Supports multi-day, multi-location events
**Generic Relationship Pattern:**
Registrations has three relationship tables:
* `registrations_signups_relationships` — signup → campus, category, signup location, signup times
* `registrations_registrations_relationships` — registration → signup, created-by person, registrant contact
* `registrations_attendees_relationships` — attendee → person, registration, selection type, emergency contact
An attendee has **no direct Signup relationship**. To go from an attendee to its
signup, hop through the registration:
`attendee -[Registration]-> registration -[Signup]-> signup`.
**Registrations relationship types come in two casings.** Planning Center
changed the casing of the Registrations relationship keys, and Parable kept
both forms in the synced data: rows created before the change carry
`snake_case` values (`signup`, `registration`, `selection_type`,
`person`, `emergency_contact`), while newer rows carry `PascalCase`
(`Signup`, `Registration`, `SelectionType`, `Person`, `EmergencyContact`).
Matching only one form silently drops roughly a third of your rows, so every
example on these pages matches both:
```sql theme={null}
AND ar.relationship_type IN ('Registration', 'registration')
```
This applies only to the Registrations module. Every other Planning Center
module uses a single, consistent casing.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Registrations module are in the `planning_center` schema. You MUST prefix all table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.registrations_signups`
❌ INCORRECT: `SELECT * FROM registrations_signups`
### Row Level Security (RLS)
This database uses Row Level Security (RLS) to automatically filter data based on:
* **tenant\_organization\_id**: You only see data for your current organization
* **system\_status**: You only see 'active' records by default
**DO NOT add these filters to your WHERE clause** - they are applied automatically:
* ❌ `WHERE tenant_organization_id = 1` (unnecessary)
* ❌ `WHERE system_status = 'active'` (unnecessary)
The RLS policies ensure you only access data you're authorized to see, making these filters redundant and potentially causing performance issues.
## Entity Tables
### registrations\_signups
Event signup forms and their configuration.
| Column | Type | Description |
| ------------------------ | ------------- | -------------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `signup_id` | VARCHAR(64) | Planning Center's signup ID |
| `name` | VARCHAR(255) | Event name |
| `description` | TEXT | Event description |
| `archived` | BOOLEAN | Whether the signup is archived |
| `open_at` | TIMESTAMP | When registration opens |
| `close_at` | TIMESTAMP | When registration closes |
| `logo_url` | VARCHAR(2048) | URL to event logo/image |
| `new_registration_url` | VARCHAR(2048) | Direct link to registration form |
| `created_at` | TIMESTAMP | When signup was created |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status (active/transferring/stale) |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_attendees
Individual attendee records for events.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `attendee_id` | VARCHAR(64) | Planning Center's attendee ID |
| `active` | BOOLEAN | Whether registration is active |
| `canceled` | BOOLEAN | Whether registration was canceled |
| `waitlisted` | BOOLEAN | Whether attendee is waitlisted |
| `waitlisted_at` | TIMESTAMP | When added to waitlist |
| `created_at` | TIMESTAMP | When attendee record was created |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_registrations
Registration submissions (minimal fields in current implementation).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `registration_id` | VARCHAR(64) | Planning Center's registration ID |
| `created_at` | TIMESTAMP | When registration was created |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_categories
Event categories for grouping and filtering.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `category_id` | VARCHAR(64) | Planning Center's category ID |
| `name` | VARCHAR(255) | Category name |
| `created_at` | TIMESTAMP | When category was created |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_selection\_types
Pricing tiers and registration options.
**This table currently syncs identifiers only, and cannot be joined.** Two
independent gaps make it unusable for reporting:
1. Every row across production has an empty `name`, `price_cents = 0`, and
`publicly_available = false` — the descriptive columns below are part of
the Planning Center model but are not populated by the sync.
2. The `SelectionType` rows in `registrations_attendees_relationships` point at
per-attendee selection objects that are not part of the synced schema, so
fewer than 0.05% of them resolve to a `selection_type_id`.
**Registration revenue and ticket-price reporting are therefore not possible**
from this module. Report on registration volume instead, and use the Giving
module for money actually received.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------ |
| `id` | UUID | Parable's unique identifier |
| `selection_type_id` | VARCHAR(64) | Planning Center's selection type ID |
| `name` | VARCHAR(255) | Selection type name |
| `price_cents` | INTEGER | Price in cents (divide by 100 for dollars) |
| `publicly_available` | BOOLEAN | Whether available to public |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_signup\_locations
Event venues with geographic coordinates.
| Column | Type | Description |
| ------------------------ | ---------------- | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `signup_location_id` | VARCHAR(64) | Planning Center's location ID |
| `name` | VARCHAR(255) | Location name |
| `formatted_address` | VARCHAR(512) | Short formatted address |
| `full_formatted_address` | VARCHAR(512) | Complete formatted address |
| `latitude` | DOUBLE PRECISION | Geographic latitude |
| `longitude` | DOUBLE PRECISION | Geographic longitude |
| `location_type` | VARCHAR(50) | Type of location |
| `subpremise` | VARCHAR(255) | Room/unit number |
| `url` | VARCHAR(2048) | URL for location info/maps |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_signup\_times
Event date and time slots.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `signup_time_id` | VARCHAR(64) | Planning Center's time ID |
| `starts_at` | TIMESTAMP | Event start time |
| `ends_at` | TIMESTAMP | Event end time |
| `all_day` | BOOLEAN | Whether this is an all-day event |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_emergency\_contacts
Emergency contact information for attendees.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `emergency_contact_id` | VARCHAR(64) | Planning Center's contact ID |
| `name` | VARCHAR(255) | Contact name |
| `phone_number` | VARCHAR(255) | Contact phone number |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_people
Basic person information for registrations.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `person_id` | VARCHAR(64) | Planning Center's person ID |
| `first_name` | VARCHAR(255) | First name |
| `last_name` | VARCHAR(255) | Last name |
| `name` | VARCHAR(255) | Full name |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_campuses
Church campus locations.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `campus_id` | VARCHAR(64) | Planning Center's campus ID |
| `city` | VARCHAR(100) | Campus city |
| `country` | VARCHAR(100) | Campus country |
| `created_at` | TIMESTAMP | When created |
| `full_formatted_address` | VARCHAR(512) | Complete formatted address |
| `name` | VARCHAR(255) | Campus name |
| `state` | VARCHAR(100) | Campus state/province |
| `street` | VARCHAR(255) | Street address |
| `updated_at` | TIMESTAMP | Last update time |
| `zip` | VARCHAR(20) | Postal/zip code |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
### registrations\_organizations
Organization settings and configuration.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `organization_id` | VARCHAR(64) | Planning Center's organization ID |
| `name` | VARCHAR(255) | Organization name |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
## Relationship Tables
### registrations\_signups\_relationships
Links signups to related entities (categories, campuses, locations, times).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `signup_id` | VARCHAR(64) | Parent signup ID |
| `relationship_type` | VARCHAR(50) | Type of relationship (Category, Campus, etc.) |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Relationship Types:**
* `Category` - Links to registrations\_categories
* `Campus` - Links to registrations\_campuses
* `SignupLocation` - Links to registrations\_signup\_locations
* `SignupTime` - Links to registrations\_signup\_times
### registrations\_registrations\_relationships
Links registrations to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `registration_id` | VARCHAR(64) | Parent registration ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Relationship Types:**
* `Signup` - Links to registrations\_signups
* `Person` - Links to registrations\_people
### registrations\_attendees\_relationships
Links attendees to related entities.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Parable's unique identifier |
| `attendee_id` | VARCHAR(64) | Parent attendee ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | ID of related entity |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data sync status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
**Relationship Types:**
* `Signup` - Links to registrations\_signups
* `Registration` - Links to registrations\_registrations
* `EmergencyContact` - Links to registrations\_emergency\_contacts
## System Fields
All tables include these system fields for data management:
* **`system_status`** - Tracks data lifecycle:
* `transferring` - Data being synced from Planning Center
* `active` - Current, queryable data
* `stale` - Outdated data pending removal
* **`tenant_organization_id`** - Ensures data isolation between organizations
* **`system_created_at`** - When Parable first received this record
* **`system_updated_at`** - Last sync timestamp
## Row Level Security
All tables implement Row Level Security (RLS) policies that:
1. Filter data to only show `active` records
2. Restrict access to the authenticated organization's data
3. Ensure complete data isolation between tenants
## Data Integrity Rules
1. **Schema Qualification**: Always use `planning_center.` prefix for all table references
2. **Row Level Security**: RLS automatically handles multi-tenancy and status filtering - do not add manual filters
3. **Monetary Values**: Cost and fee columns are stored in cents - divide by 100.0 for display
4. **Registration Status Flags**: Use booleans like `archived`, `canceled`, and `waitlisted` to control visibility instead of relying on `system_status`
5. **Direct ID Columns**: Core tables like `registrations_signups` expose direct ID columns for performance-critical joins
## Common Mistakes to Avoid
1. **Missing Schema Prefix**
* ❌ `FROM registrations_signups`
* ✅ `FROM planning_center.registrations_signups`
2. **Adding Redundant RLS Filters**
* ❌ `WHERE tenant_organization_id = 1 AND system_status = 'active'`
* ✅ Trust RLS to handle this automatically
3. **Joining Without Schema**
* ❌ `JOIN registrations_attendees a ON ...`
* ✅ `JOIN planning_center.registrations_attendees a ON ...`
4. **Skipping Currency Conversion**
* ❌ `SELECT total_cost_cents as total_cost`
* ✅ `SELECT total_cost_cents / 100.0 as total_cost`
## Performance Considerations
1. **Indexes**: All tables have optimized indexes on:
* Primary keys and entity IDs
* Join columns and foreign keys
* Date columns for time-based queries
2. **Query Optimization**:
* Always use the `planning_center.` schema prefix
* RLS handles tenant and status filtering automatically
* Filter registration status flags when relevant
* Consider CTEs for complex aggregations
* Join through the `*_relationships` tables — entity tables carry no foreign-key columns
## Best Practices
1. **Always join through relationship tables** - Relationship tables capture many-to-many links between events and related entities
2. **Trust RLS policies** - Skip manual `tenant_organization_id` or `system_status` filters
3. **Use Planning Center IDs for lookups** - The `*_id` fields (e.g., `signup_id`)
4. **Consider NULL values** - Many fields may be NULL if not provided by Planning Center
5. **Handle timestamps properly** - All timestamps are stored in UTC
## Example: Complete Event Details Query
```sql theme={null}
-- Get full event details with all related data
SELECT
s.signup_id,
s.name as event_name,
s.description,
s.open_at,
s.close_at,
cat.name as category,
camp.name as campus,
loc.name as location_name,
loc.full_formatted_address,
tim.starts_at as event_start,
tim.ends_at as event_end,
tim.all_day
FROM planning_center.registrations_signups s
-- Join category
LEFT JOIN planning_center.registrations_signups_relationships sr_cat
ON sr_cat.signup_id = s.signup_id
AND sr_cat.relationship_type IN ('Category', 'category')
LEFT JOIN planning_center.registrations_categories cat
ON cat.category_id = sr_cat.relationship_id
-- Join campus
LEFT JOIN planning_center.registrations_signups_relationships sr_camp
ON sr_camp.signup_id = s.signup_id
AND sr_camp.relationship_type IN ('Campus', 'campus')
LEFT JOIN planning_center.registrations_campuses camp
ON camp.campus_id = sr_camp.relationship_id
-- Join location
LEFT JOIN planning_center.registrations_signups_relationships sr_loc
ON sr_loc.signup_id = s.signup_id
AND sr_loc.relationship_type IN ('SignupLocation', 'signup_location')
LEFT JOIN planning_center.registrations_signup_locations loc
ON loc.signup_location_id = sr_loc.relationship_id
-- Join time
LEFT JOIN planning_center.registrations_signups_relationships sr_tim
ON sr_tim.signup_id = s.signup_id
AND sr_tim.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times tim
ON tim.signup_time_id = sr_tim.relationship_id
WHERE s.archived = false
ORDER BY tim.starts_at DESC;
```
This query demonstrates how to properly join multiple related entities through the relationship tables to get complete event information.
# Planning Center Registrations SQL Queries
Source: https://docs.getparable.io/planning-center/registrations/overview
Query Planning Center Registrations data with SQL to track event signups, manage waitlists, and analyze attendance patterns across church events.
## Manage Events and Track Signups with Data
Transform your event management with direct SQL access to Planning Center Registrations data. Track signups, manage waitlists, analyze attendance patterns, and ensure smooth event operations through powerful data insights.
**Registrations relationship types come in two casings.** Planning Center
changed the casing of the Registrations relationship keys, and Parable kept
both forms in the synced data: rows created before the change carry
`snake_case` values (`signup`, `registration`, `selection_type`,
`person`, `emergency_contact`), while newer rows carry `PascalCase`
(`Signup`, `Registration`, `SelectionType`, `Person`, `EmergencyContact`).
Matching only one form silently drops roughly a third of your rows, so every
example on these pages matches both:
```sql theme={null}
AND ar.relationship_type IN ('Registration', 'registration')
```
This applies only to the Registrations module. Every other Planning Center
module uses a single, consistent casing.
## Quick Start
Ready to explore your registrations data? Here's your first query to see active signups:
```sql theme={null}
-- See your 10 most recent event signups
SELECT
s.signup_id,
s.name as event_name,
s.description,
s.open_at,
s.close_at,
COUNT(DISTINCT a.attendee_id) as attendee_count,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlist_count
FROM planning_center.registrations_signups s
LEFT JOIN planning_center.registrations_attendees a
ON EXISTS (
SELECT 1
FROM planning_center.registrations_attendees_relationships ar
JOIN planning_center.registrations_registrations_relationships rr
ON rr.registration_id = ar.relationship_id
AND rr.relationship_type IN ('Signup', 'signup')
WHERE ar.attendee_id = a.attendee_id
AND ar.relationship_type IN ('Registration', 'registration')
AND rr.relationship_id = s.signup_id
)
WHERE s.archived = false
GROUP BY s.signup_id, s.name, s.description, s.open_at, s.close_at, s.created_at
ORDER BY s.created_at DESC
LIMIT 10;
```
## What You Can Do With Registrations Queries
### 📋 Event Management
* Track registration counts and capacity
* Monitor waitlist status and conversions
* Analyze signup patterns and trends
* Identify popular events and time slots
### 👥 Attendee Insights
* Understand attendee demographics
* Track repeat attendees across events
* Analyze cancellation patterns
* Monitor emergency contact completeness
### 📍 Location Analytics
* Analyze event locations and venues
* Track geographic distribution of attendees
* Optimize location selection
* Plan transportation and logistics
### 💰 Financial Tracking
* Monitor registration fees and pricing tiers
* Track payment collection status
* Analyze discount usage
* Report on event revenue
## Available Tables
Your Planning Center Registrations data is organized into these primary tables:
| Table | What It Contains | Key Use Cases |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `registrations_signups` | Event signup forms | Event details, dates, capacity, archives |
| `registrations_attendees` | Individual attendee records | Registration status, waitlist tracking |
| `registrations_registrations` | Registration submissions | Tracking individual registrations |
| `registrations_categories` | Event categories | Grouping and filtering events |
| `registrations_selection_types` | Pricing tiers and options — **IDs only, not usable for reporting** ([why](/planning-center/registrations/data-model#registrations-selection-types)) | Not currently reportable |
| `registrations_signup_locations` | Event venues with coordinates | Location details, mapping, directions |
| `registrations_signup_times` | Event date/time slots | Schedule management, calendar views |
| `registrations_emergency_contacts` | Emergency contact information | Safety protocols, contact lists |
| `registrations_people` | Basic person data | Attendee names and identification |
| `registrations_campuses` | Church campus locations | Multi-site event management |
| `registrations_organizations` | Organization settings | Account configuration |
## Understanding Relationships
Parable stores Planning Center relationships in separate tables to maintain data integrity. Key relationship patterns include:
* `registrations_signups_relationships` - Links signups to categories, campuses, locations, and times
* `registrations_registrations_relationships` - Links registrations to signups and people
* `registrations_attendees_relationships` - Links attendees to signups, registrations, and emergency contacts
We'll show you exactly how to join these tables in our examples!
## Key Concepts
### Registration Status
* `active` - Currently registered attendee
* `canceled` - Registration was canceled
* `waitlisted` - On the waiting list
### Signup Status
* `archived` - Event is archived (not active)
* `open_at`/`close_at` - Registration window timing
### Selection Types
* Pricing tiers for different attendee types
* Public vs. private availability
* Price stored in cents (divide by 100 for dollars)
## Navigation
Explore the complete schema with all tables and fields
Start with simple, practical SQL examples
Complex analytics with CTEs and window functions
Production-ready report templates
## Sample Insights
### Find Events with Waitlists
```sql theme={null}
-- Events with waitlists and conversion potential
SELECT
s.name as event_name,
st.starts_at as event_date,
COUNT(CASE WHEN a.active = true THEN 1 END) as registered_count,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlist_count,
sl.name as location_name
FROM planning_center.registrations_signups s
LEFT JOIN planning_center.registrations_signups_relationships sr
ON sr.signup_id = s.signup_id
AND sr.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships slr
ON slr.signup_id = s.signup_id
AND slr.relationship_type IN ('SignupLocation', 'signup_location')
LEFT JOIN planning_center.registrations_signup_locations sl
ON sl.signup_location_id = slr.relationship_id
LEFT JOIN planning_center.registrations_attendees a
ON EXISTS (
SELECT 1
FROM planning_center.registrations_attendees_relationships ar
JOIN planning_center.registrations_registrations_relationships rr
ON rr.registration_id = ar.relationship_id
AND rr.relationship_type IN ('Signup', 'signup')
WHERE ar.attendee_id = a.attendee_id
AND ar.relationship_type IN ('Registration', 'registration')
AND rr.relationship_id = s.signup_id
)
WHERE s.archived = false
AND st.starts_at >= CURRENT_DATE
GROUP BY s.name, st.starts_at, sl.name
HAVING COUNT(CASE WHEN a.waitlisted = true THEN 1 END) > 0
ORDER BY st.starts_at;
```
### Registration Timeline Analysis
```sql theme={null}
-- Registration patterns by days before event
WITH registration_timeline AS (
SELECT
s.name as event_name,
st.starts_at as event_date,
a.created_at as registration_date,
DATE_PART('day', st.starts_at - a.created_at) as days_before_event
FROM planning_center.registrations_attendees a
JOIN planning_center.registrations_attendees_relationships ar
ON ar.attendee_id = a.attendee_id
AND ar.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.registration_id = ar.relationship_id
AND ar_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_sg.relationship_id
JOIN planning_center.registrations_signups_relationships sr
ON sr.signup_id = s.signup_id
AND sr.relationship_type IN ('SignupTime', 'signup_time')
JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr.relationship_id
WHERE a.active = true
),
windowed AS (
SELECT
CASE
WHEN days_before_event >= 30 THEN '30+ days'
WHEN days_before_event >= 14 THEN '14-29 days'
WHEN days_before_event >= 7 THEN '7-13 days'
WHEN days_before_event >= 1 THEN '1-6 days'
ELSE 'Day of event'
END as registration_window
FROM registration_timeline
WHERE days_before_event >= 0
)
SELECT
registration_window,
COUNT(*) as registration_count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 2) as percentage
FROM windowed
GROUP BY registration_window
ORDER BY
CASE registration_window
WHEN '30+ days' THEN 1
WHEN '14-29 days' THEN 2
WHEN '7-13 days' THEN 3
WHEN '1-6 days' THEN 4
WHEN 'Day of event' THEN 5
END;
```
## Best Practices
1. **Always filter by archived status** - Exclude archived events for current reporting
2. **Join through relationship tables** - Use the relationship tables to connect entities
3. **Consider waitlist status** - Check both `active` and `waitlisted` fields for accurate counts
4. **Handle pricing in cents** - Remember to divide price\_cents by 100 for dollar amounts
5. **Use time zone awareness** - Event times are stored with timezone information
## Ready to Dive Deeper?
Explore our comprehensive guides to master Planning Center Registrations queries and build powerful event management insights for your ministry.
# Planning Center Registrations Report Examples
Source: https://docs.getparable.io/planning-center/registrations/reporting-examples
Production-ready Registrations reports for event teams: signup summaries, waitlist status, and attendee rosters optimized for BI tools.
## Production-Ready Reports for Event Management
Copy these complete report templates directly into your BI tools or use them as-is for comprehensive event insights. Each report is optimized for performance and includes all necessary business logic.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Registrations module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your reports.
✅ CORRECT: `SELECT * FROM planning_center.registrations_signups`
❌ INCORRECT: `SELECT * FROM registrations_signups`
### Row Level Security (RLS)
Row Level Security automatically filters results by:
* **tenant\_organization\_id** – limits data to your organization
* **system\_status** – active records returned by default
**Skip manual filters for these columns**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Focus on event-specific logic (archived flags, registration status, waitlist counts) while letting RLS manage tenancy and system status.
## Executive Dashboard Reports
### Monthly Event Summary Dashboard
```sql theme={null}
-- Executive summary of all event activity for the current month
WITH monthly_events AS (
SELECT
s.signup_id,
s.name as event_name,
s.archived,
cat.name as category,
camp.name as campus,
loc.name as location,
st.starts_at as event_date,
st.ends_at as event_end,
s.open_at as registration_open,
s.close_at as registration_close
FROM planning_center.registrations_signups s
LEFT JOIN planning_center.registrations_signups_relationships sr_cat
ON sr_cat.signup_id = s.signup_id
AND sr_cat.relationship_type IN ('Category', 'category')
LEFT JOIN planning_center.registrations_categories cat
ON cat.category_id = sr_cat.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_camp
ON sr_camp.signup_id = s.signup_id
AND sr_camp.relationship_type IN ('Campus', 'campus')
LEFT JOIN planning_center.registrations_campuses camp
ON camp.campus_id = sr_camp.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_loc
ON sr_loc.signup_id = s.signup_id
AND sr_loc.relationship_type IN ('SignupLocation', 'signup_location')
LEFT JOIN planning_center.registrations_signup_locations loc
ON loc.signup_location_id = sr_loc.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
WHERE DATE_PART('month', st.starts_at) = DATE_PART('month', CURRENT_DATE)
AND DATE_PART('year', st.starts_at) = DATE_PART('year', CURRENT_DATE)
),
registration_metrics AS (
SELECT
me.signup_id,
COUNT(DISTINCT a.attendee_id) as total_registrations,
COUNT(CASE WHEN a.active = true THEN 1 END) as active_registrations,
COUNT(CASE WHEN a.canceled = true THEN 1 END) as cancellations,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlisted,
COUNT(DISTINCT ec.emergency_contact_id) as emergency_contacts_provided
FROM monthly_events me
LEFT JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = me.signup_id
LEFT JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
LEFT JOIN planning_center.registrations_attendees_relationships ar_ec
ON ar_ec.attendee_id = a.attendee_id
AND ar_ec.relationship_type IN ('EmergencyContact', 'emergency_contact')
LEFT JOIN planning_center.registrations_emergency_contacts ec
ON ec.emergency_contact_id = ar_ec.relationship_id
GROUP BY me.signup_id
)
SELECT
me.event_name,
me.event_date::date as date,
EXTRACT(DOW FROM me.event_date) as day_of_week,
COALESCE(me.category, 'Uncategorized') as category,
COALESCE(me.campus, 'All Campuses') as campus,
COALESCE(me.location, 'TBD') as location,
CASE
WHEN me.archived = true THEN 'Archived'
WHEN me.event_date < CURRENT_TIMESTAMP THEN 'Completed'
WHEN me.registration_close < CURRENT_TIMESTAMP THEN 'Registration Closed'
WHEN me.registration_open > CURRENT_TIMESTAMP THEN 'Not Yet Open'
ELSE 'Open for Registration'
END as status,
COALESCE(rm.total_registrations, 0) as total_registrations,
COALESCE(rm.active_registrations, 0) as confirmed,
COALESCE(rm.cancellations, 0) as canceled,
COALESCE(rm.waitlisted, 0) as waitlisted,
CASE
WHEN rm.total_registrations > 0
THEN ROUND(rm.cancellations * 100.0 / rm.total_registrations, 1)
ELSE 0
END as cancellation_rate,
CASE
WHEN rm.active_registrations > 0
THEN ROUND(rm.emergency_contacts_provided * 100.0 / rm.active_registrations, 1)
ELSE 0
END as emergency_contact_completion,
COALESCE(rm.emergency_contacts_provided, 0) as emergency_contacts_provided
FROM monthly_events me
LEFT JOIN registration_metrics rm ON rm.signup_id = me.signup_id
ORDER BY me.event_date;
```
### Year-to-Date Performance Report
```sql theme={null}
-- Comprehensive YTD metrics with comparisons to last year
WITH ytd_data AS (
SELECT
DATE_PART('year', st.starts_at) as year,
DATE_PART('month', st.starts_at) as month,
cat.name as category,
COUNT(DISTINCT s.signup_id) as event_count,
COUNT(DISTINCT a.attendee_id) as total_attendees,
COUNT(CASE WHEN a.active = true THEN 1 END) as active_registrations,
COUNT(CASE WHEN a.canceled = true THEN 1 END) as cancellations,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlisted
FROM planning_center.registrations_signups s
LEFT JOIN planning_center.registrations_signups_relationships sr_cat
ON sr_cat.signup_id = s.signup_id
AND sr_cat.relationship_type IN ('Category', 'category')
LEFT JOIN planning_center.registrations_categories cat
ON cat.category_id = sr_cat.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
LEFT JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
LEFT JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
WHERE s.archived = false
AND st.starts_at >= DATE_TRUNC('year', CURRENT_DATE - INTERVAL '1 year')
AND st.starts_at <= CURRENT_DATE
GROUP BY DATE_PART('year', st.starts_at), DATE_PART('month', st.starts_at), cat.name
),
comparison AS (
SELECT
COALESCE(category, 'Uncategorized') as category,
SUM(CASE WHEN year = DATE_PART('year', CURRENT_DATE) THEN event_count ELSE 0 END) as events_this_year,
SUM(CASE WHEN year = DATE_PART('year', CURRENT_DATE) - 1 THEN event_count ELSE 0 END) as events_last_year,
SUM(CASE WHEN year = DATE_PART('year', CURRENT_DATE) THEN active_registrations ELSE 0 END) as registrations_this_year,
SUM(CASE WHEN year = DATE_PART('year', CURRENT_DATE) - 1 THEN active_registrations ELSE 0 END) as registrations_last_year,
SUM(CASE WHEN year = DATE_PART('year', CURRENT_DATE) THEN cancellations ELSE 0 END) as cancellations_this_year,
SUM(CASE WHEN year = DATE_PART('year', CURRENT_DATE) - 1 THEN cancellations ELSE 0 END) as cancellations_last_year,
SUM(CASE WHEN year = DATE_PART('year', CURRENT_DATE) THEN waitlisted ELSE 0 END) as waitlisted_this_year,
SUM(CASE WHEN year = DATE_PART('year', CURRENT_DATE) - 1 THEN waitlisted ELSE 0 END) as waitlisted_last_year
FROM ytd_data
GROUP BY category
)
SELECT
category,
events_this_year,
events_last_year,
CASE
WHEN events_last_year > 0
THEN ROUND((events_this_year - events_last_year) * 100.0 / events_last_year, 1)
ELSE NULL
END as event_growth_pct,
registrations_this_year,
registrations_last_year,
CASE
WHEN registrations_last_year > 0
THEN ROUND((registrations_this_year - registrations_last_year) * 100.0 / registrations_last_year, 1)
ELSE NULL
END as registration_growth_pct,
ROUND(cancellations_this_year * 100.0 / NULLIF(registrations_this_year + cancellations_this_year, 0), 1) as cancellation_rate_this_year,
ROUND(cancellations_last_year * 100.0 / NULLIF(registrations_last_year + cancellations_last_year, 0), 1) as cancellation_rate_last_year,
waitlisted_this_year,
waitlisted_last_year
FROM comparison
ORDER BY registrations_this_year DESC;
```
## Operational Reports
### Event Roster with Emergency Contacts
```sql theme={null}
-- Complete attendee roster for event operations
WITH event_roster AS (
SELECT
s.name as event_name,
st.starts_at as event_date,
loc.name as location_name,
loc.formatted_address as location_address,
p.person_id,
p.name as attendee_name,
p.first_name,
p.last_name,
CASE
WHEN a.waitlisted = true THEN 'Waitlisted'
WHEN a.canceled = true THEN 'Canceled'
WHEN a.active = true THEN 'Confirmed'
ELSE 'Unknown'
END as registration_status,
a.created_at as registered_date,
a.waitlisted_at,
ec.name as emergency_contact_name,
ec.phone_number as emergency_contact_phone,
ROW_NUMBER() OVER (
PARTITION BY s.signup_id
ORDER BY
CASE WHEN a.active = true THEN 1
WHEN a.waitlisted = true THEN 2
ELSE 3 END,
a.created_at
) as roster_number
FROM planning_center.registrations_signups s
-- Join to time
JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
-- Join to location
LEFT JOIN planning_center.registrations_signups_relationships sr_loc
ON sr_loc.signup_id = s.signup_id
AND sr_loc.relationship_type IN ('SignupLocation', 'signup_location')
LEFT JOIN planning_center.registrations_signup_locations loc
ON loc.signup_location_id = sr_loc.relationship_id
-- Join to attendees
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.relationship_type IN ('Signup', 'signup')
AND ar_signup_sg.relationship_id = s.signup_id
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.relationship_type IN ('Registration', 'registration')
AND ar_signup.relationship_id = ar_signup_sg.registration_id
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar_signup.attendee_id
-- Join to person
JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.attendee_id = a.attendee_id
AND ar_person.relationship_type IN ('Person', 'person')
JOIN planning_center.registrations_people p
ON p.person_id = ar_person.relationship_id
-- Join to emergency contact
LEFT JOIN planning_center.registrations_attendees_relationships ar_ec
ON ar_ec.attendee_id = a.attendee_id
AND ar_ec.relationship_type IN ('EmergencyContact', 'emergency_contact')
LEFT JOIN planning_center.registrations_emergency_contacts ec
ON ec.emergency_contact_id = ar_ec.relationship_id
WHERE s.archived = false
AND st.starts_at >= CURRENT_DATE
AND st.starts_at <= CURRENT_DATE + INTERVAL '30 days'
)
SELECT
event_name,
event_date::date as date,
TO_CHAR(event_date, 'HH:MI AM') as start_time,
location_name,
location_address,
roster_number,
attendee_name,
first_name,
last_name,
registration_status,
registered_date::date as registration_date,
CASE
WHEN registration_status = 'Waitlisted'
THEN DATE_PART('day', CURRENT_TIMESTAMP - waitlisted_at) || ' days'
ELSE NULL
END as days_on_waitlist,
emergency_contact_name,
emergency_contact_phone,
CASE
WHEN emergency_contact_name IS NULL
THEN 'Missing'
ELSE 'Provided'
END as emergency_contact_status
FROM event_roster
ORDER BY event_date, event_name, roster_number;
```
### Waitlist Management Report
```sql theme={null}
-- Active waitlists with contact information for follow-up
WITH waitlist_details AS (
SELECT
s.signup_id,
s.name as event_name,
st.starts_at as event_date,
cat.name as category,
p.person_id,
p.name as attendee_name,
a.waitlisted_at,
CURRENT_TIMESTAMP - a.waitlisted_at as time_waiting,
ROW_NUMBER() OVER (
PARTITION BY s.signup_id
ORDER BY a.waitlisted_at
) as waitlist_position,
COUNT(*) OVER (PARTITION BY s.signup_id) as total_waitlisted,
-- Get contact info from People module if available
pe.address as primary_email,
pn.number as primary_phone
FROM planning_center.registrations_attendees a
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.attendee_id = a.attendee_id
AND ar_signup.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.registration_id = ar_signup.relationship_id
AND ar_signup_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_signup_sg.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_cat
ON sr_cat.signup_id = s.signup_id
AND sr_cat.relationship_type IN ('Category', 'category')
LEFT JOIN planning_center.registrations_categories cat
ON cat.category_id = sr_cat.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.attendee_id = a.attendee_id
AND ar_person.relationship_type IN ('Person', 'person')
JOIN planning_center.registrations_people p
ON p.person_id = ar_person.relationship_id
LEFT JOIN planning_center.people_people pp
ON pp.person_id = p.person_id
LEFT JOIN planning_center.people_emails_relationships em_r
ON em_r.relationship_id = pp.person_id AND em_r.relationship_type IN ('Person', 'person')
LEFT JOIN planning_center.people_emails pe
ON pe.email_id = em_r.email_id AND pe.is_primary = true
LEFT JOIN planning_center.people_phone_numbers_relationships pn_r
ON pn_r.relationship_id = pp.person_id AND pn_r.relationship_type IN ('Person', 'person')
LEFT JOIN planning_center.people_phone_numbers pn
ON pn.phone_number_id = pn_r.phone_number_id AND pn.is_primary = true
WHERE a.waitlisted = true
AND s.archived = false
AND st.starts_at >= CURRENT_DATE
),
recent_activity AS (
SELECT
s.signup_id,
COUNT(CASE
WHEN a.canceled = true
AND a.updated_at >= CURRENT_DATE - INTERVAL '7 days'
THEN 1
END) as recent_cancellations
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
GROUP BY s.signup_id
)
SELECT
wd.event_name,
wd.event_date::date as event_date,
DATE_PART('day', wd.event_date - CURRENT_DATE) as days_until_event,
COALESCE(wd.category, 'Uncategorized') as category,
wd.waitlist_position,
wd.total_waitlisted,
wd.attendee_name,
COALESCE(wd.primary_email, 'No email on file') as email,
COALESCE(wd.primary_phone, 'No phone on file') as phone,
wd.waitlisted_at::date as waitlist_date,
EXTRACT(DAY FROM wd.time_waiting) as days_waiting,
COALESCE(ra.recent_cancellations, 0) as spots_opened_this_week,
CASE
WHEN wd.waitlist_position <= ra.recent_cancellations
THEN 'High - Likely to get spot'
WHEN wd.waitlist_position <= wd.total_waitlisted * 0.3
THEN 'Medium - Possible opening'
ELSE 'Low - Unlikely this week'
END as conversion_likelihood
FROM waitlist_details wd
LEFT JOIN recent_activity ra ON ra.signup_id = wd.signup_id
ORDER BY
wd.event_date,
wd.event_name,
wd.waitlist_position;
```
## Category Performance Reports
Registrations carries no money. `registrations_selection_types` syncs
identifiers only (empty `name`, `price_cents = 0`), and its rows cannot be
joined back to attendees — so event revenue, ticket price, and
revenue-per-attendee cannot be reported from this module. Use the Giving
module for money actually received. The report below ranks categories by
**demand**, which the data does support.
### Category Performance Report
```sql theme={null}
-- Category-level demand metrics for strategic planning
WITH category_metrics AS (
SELECT
COALESCE(cat.name, 'Uncategorized') as category,
DATE_PART('quarter', st.starts_at) as quarter,
DATE_PART('year', st.starts_at) as year,
COUNT(DISTINCT s.signup_id) as events,
COUNT(DISTINCT a.attendee_id) as unique_attendees,
COUNT(CASE WHEN a.active = true THEN 1 END) as registrations,
COUNT(CASE WHEN a.canceled = true THEN 1 END) as cancellations,
COUNT(CASE WHEN a.waitlisted = true THEN 1 END) as waitlisted
FROM planning_center.registrations_signups s
LEFT JOIN planning_center.registrations_signups_relationships sr_cat
ON sr_cat.signup_id = s.signup_id
AND sr_cat.relationship_type IN ('Category', 'category')
LEFT JOIN planning_center.registrations_categories cat
ON cat.category_id = sr_cat.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
LEFT JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
LEFT JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
LEFT JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
LEFT JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
WHERE s.archived = false
AND st.starts_at >= DATE_TRUNC('year', CURRENT_DATE)
GROUP BY cat.name, DATE_PART('quarter', st.starts_at), DATE_PART('year', st.starts_at)
),
category_rankings AS (
SELECT
category,
'Q' || quarter || ' ' || year as period,
quarter,
year,
events,
unique_attendees,
registrations,
ROUND(cancellations * 100.0 / NULLIF(registrations + cancellations, 0), 1) as cancellation_rate,
ROUND(waitlisted * 100.0 / NULLIF(registrations, 0), 1) as waitlist_rate,
ROUND(registrations::NUMERIC / NULLIF(events, 0), 1) as avg_registrations_per_event,
RANK() OVER (PARTITION BY quarter, year ORDER BY registrations DESC) as attendance_rank
FROM category_metrics
)
SELECT
category,
period,
events,
unique_attendees,
registrations,
avg_registrations_per_event,
cancellation_rate,
waitlist_rate,
attendance_rank,
CASE
WHEN attendance_rank <= 3 AND COALESCE(cancellation_rate, 0) <= 20 THEN 'Star Performer'
WHEN attendance_rank <= 3 THEN 'Strong Performer'
WHEN cancellation_rate > 20 THEN 'Needs Attention'
ELSE 'Standard'
END as performance_tier
FROM category_rankings
ORDER BY period DESC, attendance_rank;
```
## Trend Analysis Reports
### Registration Velocity Trends
```sql theme={null}
-- Track registration patterns to optimize marketing timing
WITH daily_registrations AS (
SELECT
s.signup_id,
s.name as event_name,
st.starts_at as event_date,
DATE(a.created_at) as registration_date,
st.starts_at::date - a.created_at::date as days_before_event,
COUNT(*) as daily_registrations,
SUM(COUNT(*)) OVER (
PARTITION BY s.signup_id
ORDER BY DATE(a.created_at)
ROWS UNBOUNDED PRECEDING
) as cumulative_registrations
FROM planning_center.registrations_signups s
JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
JOIN planning_center.registrations_registrations_relationships ar_sg
ON ar_sg.relationship_type IN ('Signup', 'signup')
AND ar_sg.relationship_id = s.signup_id
JOIN planning_center.registrations_attendees_relationships ar
ON ar.relationship_type IN ('Registration', 'registration')
AND ar.relationship_id = ar_sg.registration_id
JOIN planning_center.registrations_attendees a
ON a.attendee_id = ar.attendee_id
AND a.active = true
WHERE s.archived = false
AND st.starts_at >= CURRENT_DATE - INTERVAL '90 days'
AND st.starts_at <= CURRENT_DATE + INTERVAL '90 days'
GROUP BY s.signup_id, s.name, st.starts_at, DATE(a.created_at)
),
velocity_patterns AS (
SELECT
CASE
WHEN days_before_event >= 60 THEN '60+ days out'
WHEN days_before_event >= 30 THEN '30-59 days out'
WHEN days_before_event >= 14 THEN '14-29 days out'
WHEN days_before_event >= 7 THEN '7-13 days out'
WHEN days_before_event >= 1 THEN '1-6 days out'
WHEN days_before_event = 0 THEN 'Day of event'
ELSE 'After event start'
END as registration_window,
COUNT(DISTINCT event_name) as events,
SUM(daily_registrations) as total_registrations,
AVG(daily_registrations) as avg_daily_registrations,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY daily_registrations) as median_daily_registrations,
MAX(daily_registrations) as peak_daily_registrations
FROM daily_registrations
WHERE days_before_event >= 0
GROUP BY registration_window
)
SELECT
registration_window,
events,
total_registrations,
ROUND(avg_daily_registrations::numeric, 1) as avg_daily,
ROUND(median_daily_registrations::numeric, 1) as median_daily,
peak_daily_registrations as peak_daily,
ROUND(total_registrations * 100.0 / SUM(total_registrations) OVER (), 1) as pct_of_total,
SUM(total_registrations) OVER (
ORDER BY
CASE registration_window
WHEN '60+ days out' THEN 1
WHEN '30-59 days out' THEN 2
WHEN '14-29 days out' THEN 3
WHEN '7-13 days out' THEN 4
WHEN '1-6 days out' THEN 5
WHEN 'Day of event' THEN 6
ELSE 7
END
) as cumulative_total,
ROUND(
SUM(total_registrations) OVER (
ORDER BY
CASE registration_window
WHEN '60+ days out' THEN 1
WHEN '30-59 days out' THEN 2
WHEN '14-29 days out' THEN 3
WHEN '7-13 days out' THEN 4
WHEN '1-6 days out' THEN 5
WHEN 'Day of event' THEN 6
ELSE 7
END
) * 100.0 / SUM(total_registrations) OVER (),
1
) as cumulative_pct
FROM velocity_patterns
ORDER BY
CASE registration_window
WHEN '60+ days out' THEN 1
WHEN '30-59 days out' THEN 2
WHEN '14-29 days out' THEN 3
WHEN '7-13 days out' THEN 4
WHEN '1-6 days out' THEN 5
WHEN 'Day of event' THEN 6
ELSE 7
END;
```
## Export Templates
### CSV Export for Mail Merge
```sql theme={null}
-- Export format for email campaigns and mail merge
SELECT
p.first_name,
p.last_name,
p.name as full_name,
s.name as event_name,
st.starts_at::date as event_date,
TO_CHAR(st.starts_at, 'FMDay, FMMonth DD at HH:MI AM') as event_datetime_formatted,
loc.name as venue_name,
loc.formatted_address as venue_address,
CASE
WHEN a.waitlisted = true THEN 'You are on the waitlist'
WHEN a.active = true THEN 'Your registration is confirmed'
ELSE 'Registration status pending'
END as status_message,
s.new_registration_url as registration_link,
pe.address as email_address
FROM planning_center.registrations_attendees a
JOIN planning_center.registrations_attendees_relationships ar_signup
ON ar_signup.attendee_id = a.attendee_id
AND ar_signup.relationship_type IN ('Registration', 'registration')
JOIN planning_center.registrations_registrations_relationships ar_signup_sg
ON ar_signup_sg.registration_id = ar_signup.relationship_id
AND ar_signup_sg.relationship_type IN ('Signup', 'signup')
JOIN planning_center.registrations_signups s
ON s.signup_id = ar_signup_sg.relationship_id
JOIN planning_center.registrations_signups_relationships sr_time
ON sr_time.signup_id = s.signup_id
AND sr_time.relationship_type IN ('SignupTime', 'signup_time')
JOIN planning_center.registrations_signup_times st
ON st.signup_time_id = sr_time.relationship_id
LEFT JOIN planning_center.registrations_signups_relationships sr_loc
ON sr_loc.signup_id = s.signup_id
AND sr_loc.relationship_type IN ('SignupLocation', 'signup_location')
LEFT JOIN planning_center.registrations_signup_locations loc
ON loc.signup_location_id = sr_loc.relationship_id
JOIN planning_center.registrations_attendees_relationships ar_person
ON ar_person.attendee_id = a.attendee_id
AND ar_person.relationship_type IN ('Person', 'person')
JOIN planning_center.registrations_people p
ON p.person_id = ar_person.relationship_id
LEFT JOIN planning_center.people_people pp
ON pp.person_id = p.person_id
LEFT JOIN planning_center.people_emails_relationships em_r
ON em_r.relationship_id = pp.person_id AND em_r.relationship_type IN ('Person', 'person')
LEFT JOIN planning_center.people_emails pe
ON pe.email_id = em_r.email_id AND pe.is_primary = true
WHERE s.archived = false
AND st.starts_at >= CURRENT_DATE
AND st.starts_at <= CURRENT_DATE + INTERVAL '30 days'
AND pe.address IS NOT NULL
ORDER BY st.starts_at, s.name, p.last_name, p.first_name;
```
## Performance Notes
1. **Read-only access** - Your Parable connection grants `SELECT` only. Materialized views, indexes, and other DDL are not available; cache frequently accessed summaries in your BI tool or save them as Parable reports.
2. **Date filters first** - Narrow by event date or registration date before joining relationship tables.
3. **Batch Processing** - Run heavy reports during off-peak hours
4. **Caching** - Cache executive dashboard queries that don't need the most current data
## Customization Tips
* Replace date ranges to match your reporting periods
* Add campus or location filters for multi-site churches
* Modify category groupings to match your event structure
* Adjust financial calculations based on your fee structure
* Add custom fields from People module for deeper demographics
# Advanced Planning Center Services Queries
Source: https://docs.getparable.io/planning-center/services/advanced-queries
Advanced Services SQL to identify volunteer burnout, measure scheduling gaps, and analyze song rotation and team coverage across service plans.
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.
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 the `planning_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
**Avoid adding these filters manually**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Center your filters on scheduling, volunteer, and worship-specific logic while relying on RLS for tenancy and status.
## Volunteer Analytics
### Volunteer Burnout Detection
```sql theme={null}
-- 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
```sql theme={null}
-- 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
```sql theme={null}
-- 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
```sql theme={null}
-- Analyze song usage patterns and suggest rotation
WITH song_dates AS (
SELECT
s.song_id,
s.title,
s.author,
pl.sort_date,
LAG(pl.sort_date) OVER (PARTITION BY s.song_id ORDER BY pl.sort_date) as prev_date
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'
),
song_usage AS (
SELECT
song_id,
title,
author,
COUNT(DISTINCT sort_date) as total_uses,
COUNT(DISTINCT DATE_TRUNC('month', sort_date)) as months_used,
MIN(sort_date) as first_used,
MAX(sort_date) as last_used,
AVG(sort_date - prev_date) as avg_days_between
FROM song_dates
GROUP BY song_id, title, author
),
song_categories AS (
SELECT
song_id,
title,
author,
total_uses,
months_used,
last_used,
CURRENT_DATE - last_used::date as days_since_last,
EXTRACT(DAYS FROM avg_days_between) as typical_gap_days,
CASE
WHEN total_uses >= 20 AND CURRENT_DATE - last_used::date > 60 THEN 'Overdue - High Rotation'
WHEN total_uses >= 10 AND CURRENT_DATE - last_used::date > 90 THEN 'Overdue - Medium Rotation'
WHEN total_uses >= 5 AND CURRENT_DATE - last_used::date > 120 THEN 'Overdue - Low Rotation'
WHEN CURRENT_DATE - last_used::date < 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
```sql theme={null}
-- 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
```sql theme={null}
-- 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
```sql theme={null}
-- 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_person_times ppt ON ppt.plan_id = pl.plan_id
LEFT JOIN planning_center.services_plan_times pt ON pt.plan_time_id = ppt.plan_time_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::numeric, 1) as avg_minutes,
ROUND(median_length::numeric, 1) as median_minutes,
ROUND(stddev_length::numeric, 1) as variation,
ROUND(min_length::numeric, 0) || '-' || ROUND(max_length::numeric, 0) as range_minutes,
ROUND(q1_length::numeric, 0) || '-' || ROUND(q3_length::numeric, 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
```sql theme={null}
-- 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
```sql theme={null}
-- 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,
both_confirm_rate,
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
```sql theme={null}
-- 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
```sql theme={null}
-- 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](/planning-center/services/reporting-examples) for:
* Complete volunteer dashboards
* Worship planning analytics
* Team health scorecards
* Service effectiveness metrics
* Multi-campus coordination reports
# Basic Planning Center Services Queries
Source: https://docs.getparable.io/planning-center/services/basic-queries
Simple SQL for Planning Center Services: who is scheduled this Sunday, confirmed and unconfirmed volunteers, song usage, and unfilled positions.
This guide provides simple, ready-to-use SQL queries for Planning Center Services data. Each query is designed to answer common ministry questions without requiring deep SQL knowledge.
These example queries demonstrate common patterns but may require adjustments to match your specific database schema and field names. Test thoroughly in your environment before use.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Services module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your SQL.
✅ CORRECT: `SELECT * FROM planning_center.services_plans`
❌ INCORRECT: `SELECT * FROM services_plans`
### Row Level Security (RLS)
Row Level Security automatically manages:
* **tenant\_organization\_id** – isolates results to your organization
* **system\_status** – returns active records by default
**Avoid adding these filters manually**—RLS already enforces them and redundant predicates can hide data or slow queries:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Concentrate on scheduling, volunteer, and song-specific filters while trusting RLS to handle tenancy and status.
## This Week's Schedule
### Who's Serving This Sunday?
```sql theme={null}
-- List everyone scheduled for this Sunday's services
SELECT
p.full_name,
pp.team_position_name as role,
t.name as team,
pl.title as service,
pl.short_dates as date,
pp.status
FROM planning_center.services_plan_people pp
JOIN planning_center.services_people p ON pp.person_id = p.person_id
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 pl.sort_date >= CURRENT_DATE
AND pl.sort_date < CURRENT_DATE + INTERVAL '7 days'
ORDER BY pl.sort_date, t.name, pp.team_position_name;
```
### Team Status Summary
```sql theme={null}
-- See confirmed vs unconfirmed for this week
SELECT
t.name as team,
COUNT(*) as total_scheduled,
COUNT(CASE WHEN pp.status = 'C' THEN 1 END) as confirmed,
COUNT(CASE WHEN pp.status = 'U' THEN 1 END) as unconfirmed,
COUNT(CASE WHEN pp.status = 'D' THEN 1 END) as declined
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 pl.sort_date >= CURRENT_DATE
AND pl.sort_date < CURRENT_DATE + INTERVAL '7 days'
GROUP BY t.name
ORDER BY total_scheduled DESC;
```
### Upcoming Service Plans
```sql theme={null}
-- Next 4 weeks of services
SELECT
st.name as service_type,
p.title,
p.series_title,
p.short_dates as dates,
p.plan_people_count as volunteers,
p.needed_positions_count as open_positions,
p.total_length / 60 as duration_minutes
FROM planning_center.services_plans p
JOIN planning_center.services_service_types st ON p.service_type_id = st.service_type_id
WHERE p.sort_date >= CURRENT_DATE
AND p.sort_date <= CURRENT_DATE + INTERVAL '28 days'
ORDER BY p.sort_date;
```
## Song Analytics
### Most Used Songs (Last 3 Months)
```sql theme={null}
-- Top 20 most frequently used songs
SELECT
s.title,
s.author,
s.ccli,
COUNT(DISTINCT i.plan_id) as times_used,
STRING_AGG(DISTINCT a.chord_chart_key, ', ') as keys_used,
MAX(pl.sort_date) as last_used
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
LEFT 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'
GROUP BY s.song_id, s.title, s.author, s.ccli
ORDER BY times_used DESC
LIMIT 20;
```
### Songs by Theme
```sql theme={null}
-- Find songs by theme/tag
SELECT
title,
author,
themes,
last_scheduled_at,
CASE
WHEN hidden = true THEN 'Hidden'
ELSE 'Active'
END as status
FROM planning_center.services_songs
WHERE LOWER(themes) LIKE '%worship%'
OR LOWER(themes) LIKE '%communion%'
ORDER BY last_scheduled_at DESC NULLS LAST;
```
### Song Key Preferences
```sql theme={null}
-- Most common keys for frequently used songs
SELECT
s.title,
a.chord_chart_key as song_key,
COUNT(*) as times_in_key
FROM planning_center.services_items i
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
JOIN planning_center.services_plans p ON i.plan_id = p.plan_id
WHERE p.sort_date >= CURRENT_DATE - INTERVAL '6 months'
AND a.chord_chart_key IS NOT NULL
GROUP BY s.title, a.chord_chart_key
ORDER BY s.title, times_in_key DESC;
```
## Volunteer Management
### Team Participation This Month
```sql theme={null}
-- How many times each person served this month
SELECT
p.full_name,
t.name as team,
COUNT(DISTINCT pl.plan_id) 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_teams t ON pp.team_id = t.team_id
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
WHERE pl.sort_date >= DATE_TRUNC('month', CURRENT_DATE)
AND pl.sort_date < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
GROUP BY p.full_name, t.name
HAVING COUNT(DISTINCT pl.plan_id) > 0
ORDER BY times_scheduled DESC;
```
### Volunteer Availability
```sql theme={null}
-- People with upcoming blockout dates
SELECT
p.full_name,
b.reason,
bd.starts_at::date as unavailable_date
FROM planning_center.services_blockouts b
JOIN planning_center.services_blockout_dates bd ON b.blockout_id = bd.blockout_id
JOIN planning_center.services_people p ON b.person_id = p.person_id
WHERE bd.starts_at::date >= CURRENT_DATE
AND bd.starts_at::date <= CURRENT_DATE + INTERVAL '30 days'
ORDER BY bd.starts_at, p.full_name;
```
### Team Capacity
```sql theme={null}
-- Teams and their available positions
SELECT
t.name as team,
tp.name as position,
COUNT(DISTINCT pa.person_id) as available_people
FROM planning_center.services_teams t
JOIN planning_center.services_team_positions tp ON t.team_id = tp.team_id
JOIN planning_center.services_person_team_position_assignments pa
ON tp.team_position_id = pa.team_position_id
JOIN planning_center.services_people p ON pa.person_id = p.person_id
WHERE p.archived = false
AND t.archived_at IS NULL
GROUP BY t.name, tp.name
ORDER BY t.name, tp.name;
```
## Service Planning
### Service Item Breakdown
```sql theme={null}
-- What's in this Sunday's service?
SELECT
pl.title as service,
i.sequence as order_num,
i.item_type,
i.title as item,
s.title as song_title,
i.length / 60 as minutes,
i.service_position
FROM planning_center.services_items i
JOIN planning_center.services_plans pl ON i.plan_id = pl.plan_id
LEFT JOIN planning_center.services_songs s ON i.song_id = s.song_id
WHERE pl.sort_date >= CURRENT_DATE
AND pl.sort_date < CURRENT_DATE + INTERVAL '7 days'
ORDER BY pl.sort_date, i.sequence;
```
### Service Timing Analysis
```sql theme={null}
-- Average service length by type
SELECT
st.name as service_type,
COUNT(p.plan_id) as total_services,
AVG(p.total_length) / 60 as avg_length_minutes,
MIN(p.total_length) / 60 as shortest_minutes,
MAX(p.total_length) / 60 as longest_minutes
FROM planning_center.services_plans p
JOIN planning_center.services_service_types st ON p.service_type_id = st.service_type_id
WHERE p.sort_date >= CURRENT_DATE - INTERVAL '3 months'
AND p.total_length > 0
GROUP BY st.name
ORDER BY avg_length_minutes DESC;
```
### Needed Positions
```sql theme={null}
-- Unfilled positions for upcoming services
SELECT
pl.title as service,
pl.short_dates as date,
t.name as team,
np.team_position_name as position,
np.quantity as spots_needed
FROM planning_center.services_needed_positions np
JOIN planning_center.services_plans pl ON np.plan_id = pl.plan_id
JOIN planning_center.services_teams t ON np.team_id = t.team_id
WHERE pl.sort_date >= CURRENT_DATE
AND pl.sort_date <= CURRENT_DATE + INTERVAL '14 days'
ORDER BY pl.sort_date, t.name;
```
## Quick Counts
### Total Active Volunteers
```sql theme={null}
-- Count of people who have served in last 3 months
SELECT
COUNT(DISTINCT pp.person_id) as active_volunteers
FROM planning_center.services_plan_people pp
JOIN planning_center.services_plans p ON pp.plan_id = p.plan_id
WHERE p.sort_date >= CURRENT_DATE - INTERVAL '3 months'
AND pp.status IN ('C', 'U'); -- Confirmed or Unconfirmed
```
### Songs in Library
```sql theme={null}
-- Total songs and arrangements
SELECT
COUNT(DISTINCT s.song_id) as total_songs,
COUNT(DISTINCT a.arrangement_id) as total_arrangements,
COUNT(DISTINCT CASE WHEN s.hidden = false THEN s.song_id END) as active_songs
FROM planning_center.services_songs s
LEFT JOIN planning_center.services_arrangements a ON s.song_id = a.song_id;
```
### Service Types
```sql theme={null}
-- Active service types and their frequency
SELECT
name,
frequency,
CASE
WHEN archived_at IS NULL THEN 'Active'
ELSE 'Archived'
END as status
FROM planning_center.services_service_types
ORDER BY sequence;
```
## File Attachments
### Recent Chord Charts
```sql theme={null}
-- Recently added chord charts and lead sheets
SELECT
s.title as song,
a.filename,
a.file_size / 1024 as size_kb,
a.created_at as added_date
FROM planning_center.services_attachments a
JOIN planning_center.services_songs s ON a.attachable_id = s.song_id
WHERE a.attachable_types->>'type' = 'Song'
AND (LOWER(a.filename) LIKE '%.pdf'
OR LOWER(a.content_type) LIKE '%pdf%')
ORDER BY a.created_at DESC
LIMIT 20;
```
### Plan Resources
```sql theme={null}
-- Files attached to upcoming plans
SELECT
p.title as plan,
p.short_dates as date,
a.filename,
a.display_name,
a.content_type
FROM planning_center.services_attachments a
JOIN planning_center.services_plans p ON a.attachable_id = p.plan_id
WHERE a.attachable_types->>'type' = 'Plan'
AND p.sort_date >= CURRENT_DATE
AND p.sort_date <= CURRENT_DATE + INTERVAL '7 days'
ORDER BY p.sort_date, a.created_at;
```
## Tips for Using These Queries
**Date Ranges**: Adjust the INTERVAL values to change the time period. For example, change '7 days' to '14 days' for two weeks.
**Status Codes**:
* C = Confirmed
* U = Unconfirmed
* D = Declined
**Performance**: For large databases, consider adding date filters to limit the data being processed.
## Next Steps
Ready for more complex queries? Check out our [Advanced Services Queries](/planning-center/services/advanced-queries) guide for:
* Multi-team scheduling analysis
* Volunteer burnout detection
* Song rotation optimization
* Service planning templates
* Cross-campus coordination
# Planning Center Services Data Model
Source: https://docs.getparable.io/planning-center/services/data-model
Complete reference for Planning Center Services tables in Parable: plans, teams, positions, people, songs, arrangements, and how they connect.
This document provides complete documentation of the Planning Center Services data model in Parable, including all tables, fields, and relationships.
## Overview
The Services module contains **54 tables** supporting worship planning, volunteer scheduling, song management, and service coordination across your entire ministry.
## Visual Data Model
The diagram below shows the core entities and their relationships in the Services module. Use it as a visual reference while exploring the detailed table definitions below.
### Core Entity Relationships
[Open diagram in new tab →](/diagrams/planning-center/services-data-model-01.svg)
### Key Relationships Explained
**Service Planning Hierarchy:**
1. `SERVICE_TYPE` defines worship service types (Sunday AM, Sunday PM, Youth, etc.)
2. `PLAN`s are individual service plans within a service type
3. `PLAN_TIME`s define when rehearsals and services occur
4. `ITEM`s are the ordered elements within a plan (songs, media, announcements)
**Team Structure:**
* `TEAM`s organize volunteers by function (Worship, Production, Hospitality)
* `TEAM_POSITION`s define specific roles (Vocalist, Keys, Sound Engineer)
* `PLAN_PERSON` assigns people to positions for specific plans
* Status tracking: C=Confirmed, D=Declined, U=Unconfirmed
**Song Library:**
* `SONG`s are the core song catalog
* `ARRANGEMENT`s provide different versions (Slow, Upbeat, Acoustic)
* `KEY`s define available musical keys per arrangement
* `ITEM`s link songs/arrangements to specific plans
**Scheduling System:**
* `SCHEDULE`s assign people to specific plan times
* `BLOCKOUT`s indicate when volunteers are unavailable
* `BLOCKOUT_DATE`s specify exact dates blocked out
* `SIGNUP_SHEET`s facilitate volunteer self-scheduling
**Organization:**
* `SERIES` groups related plans (sermon series, special events)
* `FOLDER`s provide hierarchical organization
* `MEDIA` tracks video, audio, and presentation files
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Services module are in the `planning_center` schema. You MUST prefix all table names with `planning_center.` in your queries.
✅ CORRECT: `SELECT * FROM planning_center.services_plans`
❌ INCORRECT: `SELECT * FROM services_plans`
### Row Level Security (RLS)
This database uses Row Level Security (RLS) to automatically filter data based on:
* **tenant\_organization\_id**: You only see data for your current organization
* **system\_status**: You only see 'active' records by default
**DO NOT add these filters to your WHERE clause** - they are applied automatically:
* ❌ `WHERE tenant_organization_id = 1` (unnecessary)
* ❌ `WHERE system_status = 'active'` (unnecessary)
The RLS policies ensure you only access data you're authorized to see, making these filters redundant and potentially causing performance issues.
## Core Tables Overview
### Primary Entity Tables
* `services_service_types` - Service categories (Sunday morning, youth, special events)
* `services_plans` - Individual service plans with dates and details
* `services_teams` - Volunteer and worship teams
* `services_people` - People who serve in ministry
* `services_songs` - Song library and metadata
* `services_arrangements` - Song arrangements and keys
* `services_items` - Service elements and order
* `services_schedules` - Person scheduling information
* `services_plan_people` - People scheduled for specific plans
* `services_attachments` - Files, charts, and media
### Supporting Entity Tables
* `services_plan_times` - Service times (9am service, 11am service, etc.)
* `services_plan_templates` - Reusable service templates
* `services_team_positions` - Roles within teams (drummer, vocalist, etc.)
* `services_person_team_position_assignments` - Who can serve in what roles
* `services_needed_positions` - Unfilled positions for services
* `services_blockouts` - When people are unavailable
* `services_blockout_dates` - Specific unavailable dates
* `services_lives` - Live service control and streaming
* `services_media` - Media files and videos
* `services_folders` - Organizational structure
### Relationship Tables
* `services_songs_relationships` - Links songs to tags and attachments
* `services_teams_relationships` - Links teams to service types
## Table Definitions
### services\_service\_types
Service types define categories of services (Sunday Morning, Youth Service, etc.).
| Column | Type | Description |
| ------------------------------ | ------------ | ---------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `service_type_id` | VARCHAR(64) | Planning Center service type ID |
| `name` | VARCHAR(255) | Service type name |
| `sequence` | INTEGER | Display order |
| `permissions` | VARCHAR(50) | Permission level |
| `attachment_types_enabled` | BOOLEAN | Whether attachment types are enabled |
| `scheduled_publish` | BOOLEAN | Whether scheduled publishing is enabled |
| `frequency` | VARCHAR(50) | Service frequency (Weekly, Monthly, etc.) |
| `archived_at` | TIMESTAMP | When service type was archived |
| `created_at` | TIMESTAMP | When created in Planning Center |
| `updated_at` | TIMESTAMP | Last update in Planning Center |
| `deleted_at` | TIMESTAMP | When deleted in Planning Center |
| `custom_item_types` | JSONB | Custom service item types configuration |
| `standard_item_types` | JSONB | Standard item types (Header, Song, Media) |
| `background_check_permissions` | VARCHAR(50) | Background check requirement level |
| `comment_permissions` | VARCHAR(50) | Who can comment on plans |
| `last_plan_from` | VARCHAR(50) | How last plan was created |
| `parent_id` | VARCHAR(64) | Parent service type ID |
| `time_preference_options` | JSONB | Available time preference options |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status: 'active', 'transferring', 'stale' |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_plans
Individual service plans containing all details for a specific service date.
| Column | Type | Description |
| ------------------------------ | ------------ | ----------------------------------- |
| `id` | UUID | Internal unique identifier |
| `plan_id` | VARCHAR(64) | Planning Center plan ID |
| `service_type_id` | VARCHAR(64) | Associated service type |
| `title` | VARCHAR(255) | Plan title |
| `series_title` | VARCHAR(255) | Sermon series title |
| `public` | BOOLEAN | Whether plan is publicly visible |
| `can_view_order` | BOOLEAN | Whether order is viewable |
| `prefers_order_view` | BOOLEAN | Whether order view is preferred |
| `multi_day` | BOOLEAN | Whether plan spans multiple days |
| `items_count` | INTEGER | Total number of service items |
| `plan_people_count` | INTEGER | Number of scheduled people |
| `needed_positions_count` | INTEGER | Number of unfilled positions |
| `plan_notes_count` | INTEGER | Number of plan notes |
| `service_time_count` | INTEGER | Number of service times |
| `rehearsal_time_count` | INTEGER | Number of rehearsal times |
| `other_time_count` | INTEGER | Number of other scheduled times |
| `total_length` | INTEGER | Total service length in seconds |
| `sort_date` | TIMESTAMP | Date used for chronological sorting |
| `last_time_at` | TIMESTAMP | Last service time |
| `dates` | VARCHAR(255) | Human-readable date string |
| `short_dates` | VARCHAR(255) | Abbreviated date string |
| `files_expire_at` | TIMESTAMP | When attached files expire |
| `planning_center_url` | TEXT | Direct link to plan |
| `reminders_disabled` | BOOLEAN | Whether reminders are turned off |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `permissions` | VARCHAR(50) | User permissions for this plan |
| `previous_plan_id` | VARCHAR(64) | Previous plan in sequence |
| `next_plan_id` | VARCHAR(64) | Next plan in sequence |
| `series_id` | VARCHAR(64) | Associated series ID |
| `created_by_id` | VARCHAR(64) | Person who created the plan |
| `updated_by_id` | VARCHAR(64) | Person who last updated the plan |
| `linked_publishing_episode_id` | VARCHAR(64) | Linked publishing episode |
| `attachment_types` | JSONB | Attachment type configuration |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | Last update in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_teams
Teams of volunteers and staff who serve in ministry.
| Column | Type | Description |
| ------------------------------- | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `team_id` | VARCHAR(64) | Planning Center team ID |
| `name` | VARCHAR(255) | Team name |
| `rehearsal_team` | BOOLEAN | Whether team attends rehearsals |
| `secure_team` | BOOLEAN | Whether team requires background checks |
| `sequence` | INTEGER | Display order |
| `schedule_to` | VARCHAR(255) | Scheduling type ('plan' or 'time') |
| `default_status` | VARCHAR(2) | Default response status |
| `default_prepare_notifications` | BOOLEAN | Default notification setting |
| `assigned_directly` | BOOLEAN | Whether people are assigned directly |
| `viewers_see` | INTEGER | What viewers can see |
| `stage_color` | VARCHAR(255) | Color for stage display |
| `stage_variant` | VARCHAR(255) | Stage display variant |
| `archived_at` | TIMESTAMP | When team was archived |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `last_plan_from` | VARCHAR(255) | How last plan was created |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | Last update in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_people
People who participate in services as volunteers, staff, or worship team members.
| Column | Type | Description |
| ------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_id` | VARCHAR(64) | Planning Center person ID |
| `first_name` | VARCHAR(255) | First name |
| `last_name` | VARCHAR(255) | Last name |
| `full_name` | VARCHAR(255) | Complete name |
| `nickname` | VARCHAR(255) | Preferred name |
| `given_name` | VARCHAR(255) | Given name |
| `middle_name` | VARCHAR(255) | Middle name |
| `name_prefix` | VARCHAR(10) | Name prefix (Mr., Dr., etc.) |
| `name_suffix` | VARCHAR(10) | Name suffix (Jr., III, etc.) |
| `birthdate` | DATE | Date of birth |
| `anniversary` | DATE | Anniversary date |
| `photo_url` | TEXT | Profile photo URL |
| `photo_thumbnail_url` | TEXT | Thumbnail photo URL |
| `preferred_app` | VARCHAR(64) | Preferred Planning Center app |
| `assigned_to_rehearsal_team` | BOOLEAN | Whether on rehearsal team |
| `archived` | BOOLEAN | Whether person is archived — **use this**, not `archived_at`, which carries the sentinel `'0001-01-01 00:00:00'` for every active person |
| `site_administrator` | BOOLEAN | Whether site admin |
| `permissions` | VARCHAR(64) | General permissions |
| `max_permissions` | VARCHAR(64) | Maximum permission level |
| `status` | VARCHAR(64) | Active status |
| `notes` | TEXT | Personal notes |
| `passed_background_check` | BOOLEAN | Background check status |
| `access_media_attachments` | BOOLEAN | Can access media |
| `access_plan_attachments` | BOOLEAN | Can access plan files |
| `access_song_attachments` | BOOLEAN | Can access song files |
| `preferred_max_plans_per_day` | INTEGER | Daily scheduling limit |
| `preferred_max_plans_per_month` | INTEGER | Monthly scheduling limit |
| `praise_charts_enabled` | BOOLEAN | PraiseCharts integration |
| `ical_code` | VARCHAR(255) | Calendar feed code |
| `logged_in_at` | TIMESTAMP | Last login time |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `archived_at` | TIMESTAMP | When archived |
| `can_edit_all_people` | BOOLEAN | Can edit all people |
| `can_view_all_people` | BOOLEAN | Can view all people |
| `legacy_id` | VARCHAR(255) | Legacy identifier |
| `me_tab` | VARCHAR(255) | Me tab visibility |
| `media_tab` | VARCHAR(255) | Media tab visibility |
| `people_tab` | VARCHAR(255) | People tab visibility |
| `plans_tab` | VARCHAR(64) | Plans tab visibility |
| `songs_tab` | VARCHAR(64) | Songs tab visibility |
| `onboardings` | TEXT\[] | Onboarding steps completed |
| `created_by_id` | VARCHAR(64) | Person who created this record |
| `updated_by_id` | VARCHAR(64) | Person who last updated this record |
| `current_folder_id` | VARCHAR(64) | Current folder |
| `emails` | JSONB | Email addresses |
| `tags` | JSONB | Associated tags |
| `team_leaders` | JSONB | Team leader assignments |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | Last update in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_songs
Complete song library with metadata for worship planning.
| Column | Type | Description |
| ---------------------------- | ----------- | -------------------------- |
| `id` | UUID | Internal unique identifier |
| `song_id` | VARCHAR(64) | Planning Center song ID |
| `title` | TEXT | Song title |
| `author` | TEXT | Song author/composer |
| `copyright` | TEXT | Copyright information |
| `admin` | TEXT | Administrator/publisher |
| `ccli` | INTEGER | CCLI license number |
| `hidden` | BOOLEAN | Whether song is hidden |
| `notes` | TEXT | Song notes |
| `themes` | TEXT | Song themes/tags |
| `last_scheduled_at` | TIMESTAMP | When last used in service |
| `last_scheduled_short_dates` | TEXT | Short date of last use |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | Last update in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_arrangements
Different arrangements and keys for songs.
| Column | Type | Description |
| ------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `arrangement_id` | VARCHAR(64) | Planning Center arrangement ID |
| `song_id` | VARCHAR(64) | Associated song |
| `name` | TEXT | Arrangement name |
| `bpm` | REAL | Beats per minute |
| `length` | INTEGER | Length in seconds |
| `meter` | VARCHAR(10) | Time signature (4/4, 3/4, etc.) |
| `notes` | TEXT | Arrangement notes |
| `chord_chart` | TEXT | Chord chart content |
| `lyrics` | TEXT | Song lyrics |
| `sequence` | TEXT\[] | Song section sequence |
| `sequence_short` | TEXT\[] | Abbreviated sequence |
| `sequence_full` | JSONB | Full detailed sequence |
| `chord_chart_key` | VARCHAR(10) | Musical key |
| `chord_chart_font` | VARCHAR(255) | Display font |
| `chord_chart_font_size` | INTEGER | Font size |
| `chord_chart_columns` | INTEGER | Column layout |
| `chord_chart_chord_color` | INTEGER | Chord color setting |
| `has_chords` | BOOLEAN | Whether chords exist |
| `has_chord_chart` | BOOLEAN | Whether chart exists |
| `lyrics_enabled` | BOOLEAN | Whether lyrics are enabled |
| `number_chart_enabled` | BOOLEAN | Nashville number system |
| `numeral_chart_enabled` | BOOLEAN | Roman numeral system |
| `print_margin` | VARCHAR(10) | Print margins |
| `print_orientation` | VARCHAR(50) | Portrait/landscape |
| `print_page_size` | VARCHAR(50) | Paper size |
| `archived_at` | TIMESTAMP | When archived. **Not NULL when active** — unarchived arrangements carry the sentinel `'0001-01-01 00:00:00'`, so filter with `archived_at = '0001-01-01 00:00:00' OR archived_at IS NULL` |
| `updated_at` | TIMESTAMP | Last update |
| `updated_by_id` | VARCHAR(64) | Person who last updated the arrangement |
| `created_by_id` | VARCHAR(64) | Person who created the arrangement |
| `keys` | JSONB | Available keys for this arrangement |
| `sections_id` | VARCHAR(64) | Reference to arrangement sections |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | Last update in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_items
Individual elements within a service plan.
| Column | Type | Description |
| ----------------------------------- | ------------ | -------------------------------- |
| `id` | UUID | Internal unique identifier |
| `item_id` | VARCHAR(64) | Planning Center item ID |
| `plan_id` | VARCHAR(64) | Associated plan |
| `title` | VARCHAR(255) | Item title |
| `sequence` | INTEGER | Order in service |
| `length` | INTEGER | Duration in seconds |
| `item_type` | VARCHAR(64) | Type (song, header, media, item) |
| `service_position` | VARCHAR(64) | Position (pre, during, post) |
| `description` | TEXT | Item description |
| `key_name` | VARCHAR(64) | Musical key |
| `html_details` | TEXT | Formatted details |
| `custom_arrangement_sequence` | TEXT\[] | Custom sequence |
| `custom_arrangement_sequence_full` | TEXT\[] | Full custom sequence |
| `custom_arrangement_sequence_short` | TEXT\[] | Short custom sequence |
| `song_id` | VARCHAR(64) | Associated song |
| `arrangement_id` | VARCHAR(64) | Associated arrangement |
| `key_id` | VARCHAR(64) | Associated key |
| `selected_layout_id` | VARCHAR(64) | Selected layout |
| `selected_background_id` | VARCHAR(64) | Selected background |
| `selected_attachment_id` | VARCHAR(64) | Selected attachment |
| `item_notes` | JSONB | Item notes |
| `item_times` | JSONB | Item times |
| `media` | JSONB | Associated media |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | Last update in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_plan\_people
People scheduled to serve in specific service plans.
| Column | Type | Description |
| ------------------------------ | ------------ | ----------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `plan_person_id` | VARCHAR(64) | Planning Center plan person ID |
| `plan_id` | VARCHAR(64) | Associated plan |
| `person_id` | VARCHAR(64) | Associated person |
| `team_id` | VARCHAR(64) | Associated team |
| `service_type_id` | VARCHAR(64) | Associated service type |
| `name` | VARCHAR(255) | Person's name |
| `status` | VARCHAR(50) | Status (C=Confirmed, U=Unconfirmed, D=Declined) |
| `team_position_name` | VARCHAR(255) | Position/role name |
| `photo_thumbnail` | TEXT | Person's photo |
| `decline_reason` | TEXT | Why declined |
| `notes` | TEXT | Scheduling notes |
| `prepare_notification` | BOOLEAN | Whether to send prep notification |
| `can_accept_partial` | BOOLEAN | Can accept partial scheduling |
| `notification_sent_at` | TIMESTAMP | When notified |
| `notification_read_at` | TIMESTAMP | When read notification |
| `notification_prepared_at` | TIMESTAMP | When notification prepared |
| `notification_changed_at` | TIMESTAMP | When notification changed |
| `notification_changed_by_name` | VARCHAR(255) | Who changed notification |
| `notification_sender_name` | VARCHAR(255) | Who sent notification |
| `status_updated_at` | TIMESTAMP | When status changed |
| `scheduled_by_id` | VARCHAR(64) | ID of the person who scheduled this volunteer |
| `responds_to_id` | VARCHAR(64) | Person this volunteer responds to |
| `times` | JSONB | Scheduled times |
| `service_times` | JSONB | Service times |
| `declined_plan_times` | JSONB | Declined plan times |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | Last update in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_schedules
Individual scheduling records for people across all services.
| Column | Type | Description |
| ------------------------------------ | ------------ | --------------------------------- |
| `id` | UUID | Internal unique identifier |
| `schedule_id` | VARCHAR(64) | Planning Center schedule ID |
| `person_id` | VARCHAR(64) | Associated person |
| `service_type_id` | VARCHAR(64) | Associated service type |
| `plan_id` | VARCHAR(64) | Associated plan |
| `plan_person_id` | VARCHAR(64) | Associated plan person |
| `team_id` | VARCHAR(64) | Associated team |
| `organization_id` | VARCHAR(64) | Associated organization |
| `responds_to_person_id` | VARCHAR(64) | Person this volunteer responds to |
| `organization_name` | VARCHAR(64) | Organization name |
| `service_type_name` | VARCHAR(64) | Service type name |
| `team_name` | VARCHAR(64) | Team name |
| `team_position_name` | VARCHAR(255) | Position name |
| `person_name` | VARCHAR(64) | Person's full name |
| `sort_date` | TIMESTAMP | Date for sorting |
| `dates` | VARCHAR(64) | Service dates |
| `short_dates` | VARCHAR(64) | Short date format |
| `status` | VARCHAR(50) | Schedule status |
| `decline_reason` | TEXT | Reason if declined |
| `can_accept_partial` | BOOLEAN | Can accept partial |
| `can_accept_partial_one_time` | BOOLEAN | One-time partial |
| `can_rehearse` | BOOLEAN | Available for rehearsal |
| `plan_visible` | BOOLEAN | Can see plan |
| `plan_visible_to_me` | BOOLEAN | Plan visible to user |
| `position_display_times` | VARCHAR(255) | When position shows |
| `responds_to_name` | VARCHAR(255) | Who responds to |
| `organization_time_zone` | VARCHAR(64) | Time zone |
| `organization_twenty_four_hour_time` | BOOLEAN | 24-hour format |
| `times` | JSONB | Scheduled times |
| `plan_times` | JSONB | Plan times |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | Last update in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_attachments
Files attached to plans, songs, or other resources.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `attachment_id` | VARCHAR(64) | Planning Center attachment ID |
| `filename` | TEXT | File name |
| `file_size` | INTEGER | Size in bytes |
| `content_type` | VARCHAR(255) | MIME type |
| `url` | TEXT | Download URL |
| `thumbnail_url` | TEXT | Thumbnail URL |
| `linked_url` | TEXT | External link |
| `pco_type` | VARCHAR(255) | Planning Center type |
| `display_name` | TEXT | Display name |
| `filetype` | VARCHAR(255) | File type |
| `remote_link` | TEXT | Remote file link |
| `page_order` | TEXT\[] | Page ordering |
| `licenses_purchased` | INTEGER | Number of licenses |
| `licenses_remaining` | INTEGER | Remaining licenses |
| `licenses_used` | INTEGER | Used licenses |
| `allow_mp3_download` | BOOLEAN | MP3 download allowed |
| `web_streamable` | BOOLEAN | Can stream on web |
| `downloadable` | BOOLEAN | Can be downloaded |
| `transposable` | BOOLEAN | Can transpose |
| `streamable` | BOOLEAN | Can stream |
| `has_preview` | BOOLEAN | Preview available |
| `content` | TEXT | Attachment content |
| `import_to_item_details` | BOOLEAN | Whether to import to item details |
| `attachable_types` | JSONB | What it's attached to (`{"id": "...", "type": "Song\|Plan\|Item\|Media\|Arrangement\|Key"}`) |
| `attachable_id` | VARCHAR(64) | Parent resource ID |
| `created_by_id` | VARCHAR(64) | Person who created the attachment |
| `updated_by_id` | VARCHAR(64) | Person who last updated the attachment |
| `administrator_id` | VARCHAR(64) | Administrator reference |
| `zooms` | JSONB | Zoom level settings |
| `created_at` | TIMESTAMP | When created |
| `updated_at` | TIMESTAMP | Last update |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When created in Parable |
| `system_updated_at` | TIMESTAMP | Last update in Parable |
| `system_integration_id` | INTEGER | Integration reference |
## Relationship Tables
### services\_songs\_relationships
Links songs to tags and attachments.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `song_id` | VARCHAR(64) | Song ID |
| `relationship_type` | VARCHAR(50) | Type of relationship (Tag, Attachment) |
| `relationship_id` | VARCHAR(64) | Related entity ID |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_teams\_relationships
Links teams to service types and parent teams.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `team_id` | VARCHAR(64) | Team ID |
| `relationship_type` | VARCHAR(50) | Type of relationship |
| `relationship_id` | VARCHAR(64) | Related entity ID |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
## Planning & Scheduling
### services\_plan\_person\_times
Junction table linking plan people to specific plan times.
| Column | Type | Description |
| ------------------------ | ----------- | ---------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `plan_person_time_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the plan person time was created in Planning Center |
| `status` | VARCHAR(50) | Status of the person's availability for this time (accepted, declined, etc.) |
| `updated_at` | TIMESTAMP | When the plan person time was last updated in Planning Center |
| `plan_time_id` | VARCHAR(64) | Reference to the plan time |
| `plan_id` | VARCHAR(64) | Reference to the plan |
| `plan_person_id` | VARCHAR(64) | Reference to the person scheduled for the plan |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_plan\_templates
Templates for creating new service plans with predefined structure.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `plan_template_id` | VARCHAR(64) | Planning Center entity ID |
| `can_view_order` | BOOLEAN | Whether the order of service can be viewed |
| `created_at` | TIMESTAMP | When the template was created in Planning Center |
| `item_count` | INTEGER | Number of items in the template |
| `multi_day` | BOOLEAN | Whether the template spans multiple days |
| `name` | VARCHAR(255) | Name of the plan template |
| `note_count` | INTEGER | Number of notes in the template |
| `prefers_order_view` | BOOLEAN | Whether order view is preferred over schedule view |
| `rehearsable` | BOOLEAN | Whether the plan can be rehearsed |
| `team_count` | INTEGER | Number of teams involved in the template |
| `updated_at` | TIMESTAMP | When the template was last updated in Planning Center |
| `service_type_id` | VARCHAR(64) | Reference to the service type |
| `created_by_id` | VARCHAR(64) | Person who created the template |
| `updated_by_id` | VARCHAR(64) | Person who last updated the template |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_plan\_times
Specific times associated with service plans (rehearsals, services, other events).
| Column | Type | Description |
| ---------------------------------- | ------------ | -------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `plan_time_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the plan time was created in Planning Center |
| `ends_at` | TIMESTAMP | When the plan time ends |
| `live_ends_at` | TIMESTAMP | Actual end time of the live event |
| `live_starts_at` | TIMESTAMP | Actual start time of the live event |
| `name` | VARCHAR(255) | Name of the plan time (e.g., "Sunday Morning", "Saturday Rehearsal") |
| `recorded` | BOOLEAN | Whether the plan time was recorded |
| `starts_at` | TIMESTAMP | When the plan time starts |
| `team_reminders` | JSONB | Reminder settings for teams |
| `time_type` | VARCHAR(50) | Type of time (rehearsal, service, other) |
| `updated_at` | TIMESTAMP | When the plan time was last updated in Planning Center |
| `assigned_teams` | JSONB | Teams assigned to this plan time |
| `split_team_rehearsal_assignments` | JSONB | Split team rehearsal assignment details |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_needed\_positions
Positions needed for service plans that require scheduling.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `needed_position_id` | VARCHAR(64) | Planning Center entity ID |
| `quantity` | INTEGER | Number of people needed for this position |
| `scheduled_to` | VARCHAR(255) | Type of time this position is scheduled for |
| `team_position_name` | VARCHAR(255) | Name of the team position |
| `team_id` | VARCHAR(64) | Reference to the team |
| `plan_id` | VARCHAR(64) | Reference to the plan |
| `time_id` | VARCHAR(64) | Reference to the plan time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_signup\_sheets
Signup sheets for volunteer positions on service plans.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `signup_sheet_id` | VARCHAR(64) | Planning Center entity ID |
| `display_times` | VARCHAR(50) | How times should be displayed on the signup sheet |
| `group_key` | VARCHAR(64) | Key for grouping related signup sheets |
| `position_name` | VARCHAR(255) | Name of the position being signed up for |
| `sort_date` | TIMESTAMP | Date used for sorting signup sheets |
| `sort_index` | INTEGER | Index for sorting within a group |
| `team_name` | VARCHAR(255) | Name of the team |
| `title` | VARCHAR(255) | Title of the signup sheet |
| `plan_id` | VARCHAR(64) | Reference to the plan |
| `team_position_id` | VARCHAR(64) | Reference to the team position |
| `team_id` | VARCHAR(64) | Reference to the team |
| `scheduled_people` | JSONB | People already scheduled for this position |
| `signup_sheet_metadata` | JSONB | Additional metadata about the signup sheet |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_signup\_sheet\_metadata
Metadata about specific times on signup sheets.
| Column | Type | Description |
| -------------------------- | ------------ | ---------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `signup_sheet_metadata_id` | VARCHAR(64) | Planning Center entity ID |
| `conflicts` | JSONB | Scheduling conflicts for this time |
| `ends_at` | TIMESTAMP | When the time period ends |
| `starts_at` | TIMESTAMP | When the time period starts |
| `time_name` | VARCHAR(255) | Name of the time period |
| `time_type` | VARCHAR(255) | Type of time (rehearsal, service, other) |
| `plan_time_id` | VARCHAR(64) | Reference to the plan time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_scheduled\_people
People scheduled on signup sheets.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `scheduled_person_id` | VARCHAR(64) | Planning Center entity ID |
| `full_name` | VARCHAR(255) | Full name of the scheduled person |
| `status` | VARCHAR(50) | Status of the person's signup (confirmed, declined, etc.) |
| `thumbnail` | VARCHAR(255) | URL to the person's profile photo thumbnail |
| `person_id` | VARCHAR(64) | Reference to the person |
| `signup_sheet_id` | VARCHAR(64) | Reference to the signup sheet |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_available\_signups
Available signup opportunities for people in the organization.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `available_signup_id` | VARCHAR(64) | Planning Center entity ID |
| `organization_name` | VARCHAR(255) | Name of the organization |
| `planning_center_url` | TEXT | URL to view the signup in Planning Center |
| `service_type_name` | VARCHAR(255) | Name of the service type |
| `signups_available` | BOOLEAN | Whether signups are currently available |
| `organization_id` | VARCHAR(64) | Reference to the organization |
| `person_id` | VARCHAR(64) | Reference to the person |
| `service_type_id` | VARCHAR(64) | Reference to the service type |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_attendances
Attendance records linking people to plan times through check-ins.
| Column | Type | Description |
| --------------------------- | ----------- | ---------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `attendance_id` | VARCHAR(64) | Planning Center entity ID |
| `plan_id` | VARCHAR(64) | Reference to the plan |
| `check_ins_event_id` | VARCHAR(64) | Reference to the check-ins event |
| `check_ins_event_period_id` | VARCHAR(64) | Reference to the check-ins event period |
| `checked_in_at` | TIMESTAMP | When the person checked in |
| `plan_person_id` | VARCHAR(64) | Reference to the person scheduled for the plan |
| `plan_time_id` | VARCHAR(64) | Reference to the plan time |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_blockouts
Recurring or one-time blockout periods when people are unavailable.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `blockout_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the blockout was created in Planning Center |
| `description` | TEXT | Detailed description of the blockout |
| `ends_at` | TIMESTAMP | When the blockout period ends |
| `group_identifier` | VARCHAR(64) | Identifier for grouping related blockouts |
| `organization_name` | VARCHAR(255) | Name of the organization |
| `reason` | TEXT | Reason for the blockout |
| `repeat_frequency` | VARCHAR(255) | How often the blockout repeats |
| `repeat_interval` | VARCHAR(255) | Interval for repeating blockouts |
| `repeat_period` | VARCHAR(255) | Period of repetition |
| `repeat_until` | DATE | Date when the blockout repetition ends |
| `settings` | VARCHAR(255) | Additional blockout settings |
| `share` | BOOLEAN | Whether the blockout is shared with schedulers |
| `starts_at` | TIMESTAMP | When the blockout period begins |
| `time_zone` | VARCHAR(255) | Time zone for the blockout |
| `updated_at` | TIMESTAMP | When the blockout was last updated in Planning Center |
| `person_id` | VARCHAR(64) | Reference to the person with the blockout |
| `organization_id` | VARCHAR(64) | Reference to the organization |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_blockout\_dates
Individual dates within a blockout period.
| Column | Type | Description |
| ------------------------ | ----------- | ---------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `blockout_date_id` | VARCHAR(64) | Planning Center entity ID |
| `ends_at` | TIMESTAMP | When the blockout date ends (local time) |
| `ends_at_utc` | TIMESTAMP | When the blockout date ends (UTC) |
| `group_identifier` | VARCHAR(64) | Identifier for grouping related blockout dates |
| `reason` | TEXT | Reason for this specific blockout date |
| `share` | BOOLEAN | Whether this blockout date is shared with schedulers |
| `starts_at` | TIMESTAMP | When the blockout date begins (local time) |
| `starts_at_utc` | TIMESTAMP | When the blockout date begins (UTC) |
| `time_zone` | VARCHAR(64) | Time zone for the blockout date |
| `person_id` | VARCHAR(64) | Reference to the person with the blockout |
| `blockout_id` | VARCHAR(64) | Reference to the parent blockout |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_blockout\_exceptions
Exceptions to recurring blockouts (dates when the blockout doesn't apply).
| Column | Type | Description |
| ------------------------ | ----------- | ------------------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `blockout_exception_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the exception was created in Planning Center |
| `date` | DATE | Date when the blockout exception applies |
| `updated_at` | TIMESTAMP | When the exception was last updated in Planning Center |
| `blockout_id` | VARCHAR(64) | Reference to the blockout |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_scheduling\_preferences
Scheduling preferences between household members.
| Column | Type | Description |
| -------------------------- | ------------ | ---------------------------------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `scheduling_preference_id` | VARCHAR(64) | Planning Center entity ID |
| `preference` | VARCHAR(255) | Type of scheduling preference (schedule together, avoid scheduling together, etc.) |
| `household_member_id` | VARCHAR(64) | Reference to the household member |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_time\_preference\_options
Available time preference options for scheduling team members.
| Column | Type | Description |
| --------------------------- | ----------- | --------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `time_preference_option_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the option was created in Planning Center |
| `day_of_week` | VARCHAR(50) | Day of the week for this preference |
| `description` | TEXT | Description of the time preference |
| `minute_of_day` | INTEGER | Minute of the day (0-1439) when this time occurs |
| `sort_index` | VARCHAR(50) | Index for sorting preference options |
| `starts_at` | TIMESTAMP | When the time preference starts |
| `time_type` | VARCHAR(50) | Type of time (rehearsal, service, other) |
| `updated_at` | TIMESTAMP | When the option was last updated in Planning Center |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_live\_controllers
People who can control live services.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `live_controller_id` | VARCHAR(64) | Planning Center entity ID |
| `service_type_id` | VARCHAR(64) | Reference to the service type |
| `created_at` | TIMESTAMP | When the controller was created in Planning Center |
| `full_name` | VARCHAR(255) | Full name of the controller |
| `photo_thumbnail_url` | TEXT | URL to the controller's profile photo thumbnail |
| `updated_at` | TIMESTAMP | When the controller was last updated in Planning Center |
| `person_id` | VARCHAR(64) | Reference to the person |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_lives
Live service sessions with control capabilities.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `live_id` | VARCHAR(64) | Planning Center entity ID |
| `plan_id` | VARCHAR(64) | Reference to the plan |
| `can_chat` | BOOLEAN | Whether chat is enabled for this live session |
| `can_control` | BOOLEAN | Whether the current user can control the live session |
| `can_control_video_feed` | BOOLEAN | Whether the current user can control the video feed |
| `can_take_control` | BOOLEAN | Whether the current user can take control |
| `chat_room_channel` | VARCHAR(255) | Channel identifier for the chat room |
| `dates` | VARCHAR(255) | Formatted date(s) for the live session |
| `live_channel` | VARCHAR(255) | Channel identifier for the live session |
| `series_title` | VARCHAR(255) | Title of the series |
| `title` | VARCHAR(255) | Title of the live session |
| `controller_id` | VARCHAR(64) | Reference to the person controlling the session |
| `current_item_time_id` | VARCHAR(64) | Reference to the current item time |
| `items` | JSONB | Items in the live session |
| `next_item_time_id` | VARCHAR(64) | Reference to the next item time |
| `service_type_id` | VARCHAR(64) | Reference to the service type |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_series
Service series for grouping related plans.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `series_id` | VARCHAR(64) | Planning Center entity ID |
| `artwork_content_type` | VARCHAR(64) | Content type of the artwork file |
| `artwork_file_name` | VARCHAR(255) | Original filename of the artwork |
| `artwork_file_size` | INTEGER | Size of the artwork file in bytes |
| `artwork_for_dashboard` | TEXT | URL to artwork sized for dashboard |
| `artwork_for_mobile` | TEXT | URL to artwork sized for mobile devices |
| `artwork_for_plan` | TEXT | URL to artwork sized for plan view |
| `artwork_original` | TEXT | URL to original artwork file |
| `created_at` | TIMESTAMP | When the series was created in Planning Center |
| `has_artwork` | BOOLEAN | Whether the series has associated artwork |
| `title` | VARCHAR(255) | Title of the series |
| `updated_at` | TIMESTAMP | When the series was last updated in Planning Center |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_public\_views
Public view settings for service plans.
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `public_view_id` | VARCHAR(64) | Planning Center entity ID |
| `amazon` | BOOLEAN | Whether to show Amazon Music links |
| `headers` | BOOLEAN | Whether to show section headers |
| `item_lengths` | BOOLEAN | Whether to show item lengths |
| `itunes` | BOOLEAN | Whether to show iTunes links |
| `media_items` | BOOLEAN | Whether to show media items |
| `regular_items` | BOOLEAN | Whether to show regular items |
| `series_and_plan_titles` | BOOLEAN | Whether to show series and plan titles |
| `series_artwork` | BOOLEAN | Whether to show series artwork |
| `service_times` | BOOLEAN | Whether to show service times |
| `song_items` | BOOLEAN | Whether to show song items |
| `spotify` | BOOLEAN | Whether to show Spotify links |
| `vimeo` | BOOLEAN | Whether to show Vimeo links |
| `youtube` | BOOLEAN | Whether to show YouTube links |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_contributors
People who have contributed to a plan (edits, additions, etc.).
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `contributor_id` | VARCHAR(64) | Planning Center entity ID |
| `contributable_action` | VARCHAR(255) | Type of action performed (created, updated, etc.) |
| `contributable_category` | VARCHAR(255) | Category of contribution |
| `contributable_type` | VARCHAR(255) | Type of entity contributed to |
| `created_at` | TIMESTAMP | When the contribution was made |
| `full_name` | VARCHAR(255) | Full name of the contributor |
| `photo_thumbnail_url` | TEXT | URL to the contributor's profile photo thumbnail |
| `updated_at` | TIMESTAMP | When the contribution was last updated |
| `plan_id` | VARCHAR(64) | Reference to the plan |
| `person_id` | VARCHAR(64) | Reference to the person |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_plan\_notes
Notes attached to service plans.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `plan_note_id` | VARCHAR(64) | Planning Center entity ID |
| `category_name` | VARCHAR(255) | Name of the note category |
| `content` | TEXT | Content of the note |
| `created_at` | TIMESTAMP | When the note was created in Planning Center |
| `updated_at` | TIMESTAMP | When the note was last updated in Planning Center |
| `created_by_id` | VARCHAR(64) | Person who created the note |
| `plan_note_category_id` | VARCHAR(64) | Reference to the note category |
| `teams` | JSONB | Teams that can view this note |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_plan\_note\_categories
Categories for organizing plan notes.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `plan_note_category_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the category was created in Planning Center |
| `deleted_at` | TIMESTAMP | When the category was deleted in Planning Center |
| `name` | VARCHAR(255) | Name of the category |
| `sequence` | INTEGER | Display order for the category |
| `updated_at` | TIMESTAMP | When the category was last updated in Planning Center |
| `service_type_id` | VARCHAR(64) | Reference to the service type |
| `teams` | JSONB | Teams that can use this category |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_item\_note\_categories
Categories for organizing item notes.
| Column | Type | Description |
| ------------------------ | ------------ | ----------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `item_note_category_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the category was created in Planning Center |
| `deleted_at` | TIMESTAMP | When the category was deleted in Planning Center |
| `frequently_used` | BOOLEAN | Whether this category is frequently used |
| `name` | VARCHAR(255) | Name of the category |
| `sequence` | INTEGER | Display order for the category |
| `updated_at` | TIMESTAMP | When the category was last updated in Planning Center |
| `service_type_id` | VARCHAR(64) | Reference to the service type |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
## Team Management
### services\_team\_positions
Positions within service teams.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `team_position_id` | VARCHAR(64) | Planning Center entity ID |
| `name` | VARCHAR(255) | Name of the position |
| `negative_tag_groups` | JSONB | Tag groups that exclude people from this position |
| `sequence` | INTEGER | Display order for the position |
| `tag_groups` | JSONB | Tag groups required for this position |
| `tags` | JSONB | Tags associated with this position |
| `team_id` | VARCHAR(64) | Reference to the team |
| `attachment_types` | JSONB | Types of attachments allowed for this position |
| `tag_ids` | JSONB | IDs of tags associated with this position |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_person\_team\_position\_assignments
Assignments of people to specific team positions.
| Column | Type | Description |
| ------------------------------------ | --------------- | ------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `person_team_position_assignment_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the assignment was created in Planning Center |
| `preferred_weeks` | VARCHAR(255)\[] | Array of preferred week frequencies |
| `schedule_preference` | VARCHAR(50) | Scheduling preference for this assignment |
| `updated_at` | TIMESTAMP | When the assignment was last updated in Planning Center |
| `person_id` | VARCHAR(64) | Reference to the person |
| `team_position_id` | VARCHAR(64) | Reference to the team position |
| `time_preference_options` | JSONB | Preferred times for this assignment |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_team\_leaders
Leaders assigned to service teams.
| Column | Type | Description |
| ------------------------------ | ----------- | ------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `team_leader_id` | VARCHAR(64) | Planning Center entity ID |
| `send_responses_for_accepts` | BOOLEAN | Whether to send notifications when people accept |
| `send_responses_for_blockouts` | BOOLEAN | Whether to send notifications about blockouts |
| `send_responses_for_declines` | BOOLEAN | Whether to send notifications when people decline |
| `person_id` | VARCHAR(64) | Reference to the person who is the team leader |
| `team_id` | VARCHAR(64) | Reference to the team |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_split\_team\_rehearsal\_assignments
Rehearsal time assignments for split teams.
| Column | Type | Description |
| ------------------------------------ | ----------- | ----------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `split_team_rehearsal_assignment_id` | VARCHAR(64) | Planning Center entity ID |
| `schedule_special_service_times` | BOOLEAN | Whether to schedule special service times |
| `team_id` | VARCHAR(64) | Reference to the team |
| `time_preference_options` | JSONB | Time preferences for this assignment |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
## Song & Music Management
### services\_arrangement\_sections
Sections within song arrangements (verse, chorus, bridge, etc.).
| Column | Type | Description |
| ------------------------ | ----------- | --------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `arrangement_section_id` | VARCHAR(64) | Planning Center entity ID |
| `sections` | JSONB | Array of section definitions with names and content |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_keys
Musical keys associated with song arrangements.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `key_id` | VARCHAR(64) | Planning Center entity ID |
| `alternate_keys` | JSONB | Alternative keys for this arrangement |
| `created_at` | TIMESTAMP | When the key was created in Planning Center |
| `ending_key` | VARCHAR(13) | Musical key at the end of the song |
| `ending_minor` | BOOLEAN | Whether the ending key is minor |
| `name` | VARCHAR(255) | Display name for the key |
| `starting_key` | VARCHAR(3) | Musical key at the start of the song |
| `starting_minor` | BOOLEAN | Whether the starting key is minor |
| `updated_at` | TIMESTAMP | When the key was last updated in Planning Center |
| `arrangement_id` | VARCHAR(64) | Reference to the arrangement |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_song\_schedules
Schedule information for when songs are used in plans.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `song_schedule_id` | VARCHAR(64) | Planning Center entity ID |
| `arrangement_name` | VARCHAR(255) | Name of the arrangement used |
| `key_name` | VARCHAR(255) | Musical key used |
| `plan_dates` | VARCHAR(50) | Formatted dates when the song is scheduled |
| `plan_sort_date` | TIMESTAMP | Date used for sorting song schedules |
| `service_type_name` | VARCHAR(255) | Name of the service type |
| `arrangement_id` | VARCHAR(64) | Reference to the arrangement |
| `key_id` | VARCHAR(64) | Reference to the key |
| `plan_id` | VARCHAR(64) | Reference to the plan |
| `service_type_id` | VARCHAR(64) | Reference to the service type |
| `item_id` | VARCHAR(64) | Reference to the plan item |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_media
Media items (videos, images, audio) used in services.
| Column | Type | Description |
| ------------------------ | ------------ | -------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `media_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the media was created in Planning Center |
| `creator_name` | VARCHAR(255) | Name of the person who created the media |
| `image_url` | TEXT | URL to the media image |
| `length` | INTEGER | Length of the media in seconds |
| `media_type` | VARCHAR(255) | Type of media (video, image, audio) |
| `media_type_name` | VARCHAR(255) | Display name for the media type |
| `preview_content_type` | VARCHAR(255) | Content type of the preview file |
| `preview_file_name` | TEXT | Filename of the preview |
| `preview_file_size` | INTEGER | Size of the preview file in bytes |
| `preview_updated_at` | TIMESTAMP | When the preview was last updated |
| `preview_url` | TEXT | URL to the preview file |
| `themes` | VARCHAR(255) | Themes associated with the media |
| `thumbnail_content_type` | VARCHAR(255) | Content type of the thumbnail |
| `thumbnail_file_name` | TEXT | Filename of the thumbnail |
| `thumbnail_file_size` | INTEGER | Size of the thumbnail file in bytes |
| `thumbnail_updated_at` | TIMESTAMP | When the thumbnail was last updated |
| `thumbnail_url` | TEXT | URL to the thumbnail |
| `title` | TEXT | Title of the media |
| `updated_at` | TIMESTAMP | When the media was last updated in Planning Center |
| `attachments` | JSONB | Attachments associated with this media |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_media\_schedules
Schedule information for when media is used in plans.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `media_schedule_id` | VARCHAR(64) | Planning Center entity ID |
| `media_id` | VARCHAR(64) | Reference to the media |
| `plan_dates` | VARCHAR(255) | Formatted dates when the media is scheduled |
| `plan_short_dates` | VARCHAR(255) | Short formatted dates |
| `plan_sort_date` | TIMESTAMP | Date used for sorting media schedules |
| `service_type_name` | VARCHAR(255) | Name of the service type |
| `plan_id` | VARCHAR(64) | Reference to the plan |
| `service_type_id` | VARCHAR(64) | Reference to the service type |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
## Communication & Notifications
### services\_email\_templates
Templates for automated emails sent from Planning Center Services.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `email_template_id` | VARCHAR(64) | Planning Center entity ID |
| `created_at` | TIMESTAMP | When the template was created in Planning Center |
| `html_body` | TEXT | HTML content of the email template |
| `kind` | VARCHAR(255) | Type of email template (schedule request, reminder, etc.) |
| `subject` | VARCHAR(255) | Subject line for the email |
| `updated_at` | TIMESTAMP | When the template was last updated in Planning Center |
| `template_owner_id` | VARCHAR(64) | Reference to the owner of the template |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_emails
Email addresses associated with people in Services.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `email_id` | VARCHAR(64) | Planning Center entity ID |
| `address` | VARCHAR(255) | Email address |
| `is_primary` | BOOLEAN | Whether this is the person's primary email |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_phone\_numbers
Phone numbers associated with people in Services.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `phone_number_id` | VARCHAR(64) | Planning Center entity ID |
| `carrier` | VARCHAR(255) | Mobile carrier name |
| `created_at` | TIMESTAMP | When the phone number was created in Planning Center |
| `location` | VARCHAR(255) | Type of phone number (mobile, home, work) |
| `number` | VARCHAR(255) | Phone number |
| `primary_number` | BOOLEAN | Whether this is the person's primary phone number |
| `updated_at` | TIMESTAMP | When the phone number was last updated in Planning Center |
| `e164` | VARCHAR(255) | Phone number in E.164 format |
| `person_id` | VARCHAR(64) | Reference to the person |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_text\_settings
Text messaging settings for people in Services.
| Column | Type | Description |
| ----------------------------- | ----------- | ----------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `text_setting_id` | VARCHAR(64) | Planning Center entity ID |
| `carrier` | VARCHAR(64) | Mobile carrier name |
| `display_number` | VARCHAR(64) | Formatted phone number for display |
| `general_emails_enabled` | BOOLEAN | Whether general email notifications are enabled |
| `normalized_number` | VARCHAR(64) | Normalized phone number |
| `reminders_enabled` | BOOLEAN | Whether reminder texts are enabled |
| `scheduling_replies_enabled` | BOOLEAN | Whether scheduling reply texts are enabled |
| `scheduling_requests_enabled` | BOOLEAN | Whether scheduling request texts are enabled |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
## Administration & Settings
### services\_organizations
Organization-wide settings for Planning Center Services.
| Column | Type | Description |
| ------------------------------------- | ---------------- | --------------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `organization_id` | VARCHAR(64) | Planning Center entity ID |
| `allow_mp3_download` | BOOLEAN | Whether MP3 downloads are allowed |
| `calendar_starts_on_sunday` | BOOLEAN | Whether the calendar week starts on Sunday |
| `ccli` | VARCHAR(64) | CCLI license number |
| `ccli_auto_reporting_enabled` | BOOLEAN | Whether automatic CCLI reporting is enabled |
| `ccli_connected` | BOOLEAN | Whether CCLI integration is connected |
| `ccli_reporting_enabled` | BOOLEAN | Whether CCLI reporting is enabled |
| `created_at` | TIMESTAMP | When the organization was created in Planning Center |
| `date_format` | VARCHAR(64) | Preferred date format |
| `extra_file_storage_allowed` | BOOLEAN | Whether extra file storage is allowed |
| `file_storage_exceeded` | BOOLEAN | Whether file storage limit has been exceeded |
| `file_storage_extra_charges` | DOUBLE PRECISION | Extra charges for file storage |
| `file_storage_extra_enabled` | BOOLEAN | Whether extra file storage is enabled |
| `file_storage_size` | BIGINT | Total file storage size in bytes |
| `file_storage_size_used` | BIGINT | Used file storage size in bytes |
| `legacy_id` | VARCHAR(64) | Legacy organization identifier |
| `music_stand_enabled` | BOOLEAN | Whether Music Stand app is enabled |
| `name` | VARCHAR(255) | Organization name |
| `owner_name` | VARCHAR(255) | Name of the organization owner |
| `people_allowed` | INTEGER | Number of people allowed in the organization |
| `people_remaining` | INTEGER | Number of people slots remaining |
| `projector_enabled` | BOOLEAN | Whether projector features are enabled |
| `rehearsal_mix_enabled` | BOOLEAN | Whether rehearsal mix features are enabled |
| `rehearsal_pack_connected` | BOOLEAN | Whether Rehearsal Pack is connected |
| `required_to_set_download_permission` | VARCHAR(255) | Download permission requirements |
| `secret` | VARCHAR(255) | Organization secret key |
| `time_zone` | VARCHAR(255) | Organization time zone |
| `twenty_four_hour_time` | BOOLEAN | Whether to use 24-hour time format |
| `updated_at` | TIMESTAMP | When the organization was last updated in Planning Center |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_folders
Folders for organizing service plans and songs.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `folder_id` | VARCHAR(64) | Planning Center entity ID |
| `container` | VARCHAR(255) | Type of items this folder contains |
| `created_at` | TIMESTAMP | When the folder was created in Planning Center |
| `name` | VARCHAR(255) | Name of the folder |
| `updated_at` | TIMESTAMP | When the folder was last updated in Planning Center |
| `ancestors` | JSONB | Parent folders in the hierarchy |
| `parent` | VARCHAR(64) | Reference to the parent folder |
| `campus` | VARCHAR(64) | Reference to the campus |
| `service_types` | JSONB | Service types associated with this folder |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_tags
Tags for categorizing songs, people, and other entities.
| Column | Type | Description |
| ------------------------ | ------------ | --------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `tag_id` | VARCHAR(64) | Planning Center entity ID |
| `name` | VARCHAR(255) | Name of the tag |
| `tag_group_id` | VARCHAR(64) | Reference to the tag group |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_tag\_groups
Groups for organizing related tags.
| Column | Type | Description |
| --------------------------- | ------------ | ----------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `tag_group_id` | VARCHAR(64) | Planning Center entity ID |
| `allow_multiple_selections` | BOOLEAN | Whether multiple tags from this group can be selected |
| `name` | VARCHAR(255) | Name of the tag group |
| `required` | BOOLEAN | Whether a tag from this group is required |
| `service_type_folder_name` | VARCHAR(255) | Associated service type folder name |
| `tags_for` | VARCHAR(255) | What type of entity these tags apply to |
| `folder_id` | VARCHAR(64) | Reference to the folder |
| `tags` | JSONB | Tags within this group |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_attachment\_type\_groups
Groups for organizing attachment types.
| Column | Type | Description |
| -------------------------- | ------------ | ------------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `attachment_type_group_id` | VARCHAR(64) | Planning Center entity ID |
| `name` | VARCHAR(255) | Name of the attachment type group |
| `readonly` | BOOLEAN | Whether this group is read-only (system-defined) |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_attachment\_types
Types of attachments that can be associated with plans, songs, and other entities.
| Column | Type | Description |
| -------------------------- | ------------ | ------------------------------------------------ |
| `id` | UUID | Internal unique identifier |
| `attachment_type_id` | VARCHAR(64) | Planning Center entity ID |
| `aliases` | TEXT\[] | Alternative names for this attachment type |
| `built_in` | BOOLEAN | Whether this is a built-in (system-defined) type |
| `capoed_chord_charts` | BOOLEAN | Whether to show capoed chord charts |
| `chord_charts` | BOOLEAN | Whether chord charts are enabled |
| `exclusions` | TEXT\[] | File types to exclude |
| `lyrics` | BOOLEAN | Whether lyrics are enabled |
| `name` | VARCHAR(255) | Name of the attachment type |
| `number_charts` | BOOLEAN | Whether number charts are enabled |
| `numeral_charts` | BOOLEAN | Whether numeral charts are enabled |
| `attachment_type_group_id` | VARCHAR(64) | Reference to the attachment type group |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
### services\_report\_templates
Templates for generating reports.
| Column | Type | Description |
| ------------------------ | ------------ | ------------------------------------------------- |
| `id` | UUID | Internal unique identifier |
| `report_template_id` | VARCHAR(64) | Planning Center entity ID |
| `body` | TEXT | Template body content |
| `is_default` | BOOLEAN | Whether this is the default template for its type |
| `title` | VARCHAR(255) | Title of the report template |
| `type` | VARCHAR(50) | Type of report (plan, item, etc.) |
| `tenant_organization_id` | INTEGER | Organization identifier |
| `system_status` | VARCHAR(50) | Data status |
| `system_created_at` | TIMESTAMP | When record was created in Parable |
| `system_updated_at` | TIMESTAMP | When record was last updated in Parable |
| `system_integration_id` | INTEGER | Integration reference |
## Data Relationships
### Key Relationships
1. **Service Planning Hierarchy**
* Service Types → Plans → Items
* Plans → Plan Times → Plan People
2. **Team Structure**
* Teams → Team Positions → Person Team Position Assignments
* Teams → Plan People → Schedules
3. **Song Management**
* Songs → Arrangements → Items
* Songs → Attachments (chord charts, lead sheets)
4. **Scheduling Flow**
* People → Schedules → Plan People
* People → Blockouts → Blockout Dates
5. **Resource Attachments**
* Plans → Attachments
* Songs → Attachments
* Items → Media
## Query Patterns
### Finding Scheduled Volunteers
```sql theme={null}
SELECT
pp.person_id,
p.full_name,
pp.team_position_name,
pl.title as plan_title,
pl.sort_date
FROM planning_center.services_plan_people pp
JOIN planning_center.services_people p ON pp.person_id = p.person_id
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
WHERE pp.status = 'C' -- Confirmed
AND pl.sort_date >= CURRENT_DATE
ORDER BY pl.sort_date, p.full_name;
```
### Analyzing Song Usage
```sql theme={null}
SELECT
s.title,
s.author,
COUNT(DISTINCT i.plan_id) as times_used,
MAX(pl.sort_date) as last_used
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
GROUP BY s.song_id, s.title, s.author
ORDER BY times_used DESC;
```
## Data Integrity Rules
1. **Schema Qualification**: Always use `planning_center.` prefix for all table references
2. **Row Level Security**: RLS automatically handles multi-tenancy and status filtering - do not add manual filters
3. **Monetary Values**: Any cost or fee columns are stored in cents - divide by 100.0 for display
4. **Scheduling Status Flags**: Use fields like `status` and `decline_reason` to interpret plan assignments instead of checking `system_status`
5. **Direct ID Columns**: Core tables such as `services_plans` and `services_plan_people` expose direct IDs for performance-sensitive joins
## Common Mistakes to Avoid
1. **Missing Schema Prefix**
* ❌ `FROM services_plan_people`
* ✅ `FROM planning_center.services_plan_people`
2. **Adding Redundant RLS Filters**
* ❌ `WHERE tenant_organization_id = 1 AND system_status = 'active'`
* ✅ Trust RLS to handle this automatically
3. **Joining Without Schema**
* ❌ `JOIN services_people p ON ...`
* ✅ `JOIN planning_center.services_people p ON ...`
4. **Forgetting Duration Conversion**
* ❌ `SELECT duration_seconds as minutes`
* ✅ `SELECT duration_seconds / 60.0 as minutes`
## Performance Considerations
1. **Indexes**: All tables have optimized indexes on:
* Primary keys and entity IDs
* Join columns and foreign keys
* Date columns for time-based queries
2. **Query Optimization**:
* Always use the `planning_center.` schema prefix
* RLS handles tenant and status filtering automatically
* Filter scheduling status or declined responses when relevant
* Consider CTEs for complex aggregations
* Join through the `*_relationships` tables — entity tables carry no foreign-key columns
## Data Synchronization
All Services tables use the standard Parable synchronization pattern:
* **system\_status**: Tracks data lifecycle ('transferring' → 'active' → 'stale')
* **system\_created\_at**: When record entered Parable
* **system\_updated\_at**: Last Parable update
* **created\_at/updated\_at**: Original Planning Center timestamps
## Notes
* Status codes often use single letters: C=Confirmed, U=Unconfirmed, D=Declined
* All timestamps are stored in UTC
* JSONB fields store complex nested data structures
* File URLs may expire based on `files_expire_at` timestamps
* Person records link to the main People app for complete profiles
# Planning Center Services SQL Queries
Source: https://docs.getparable.io/planning-center/services/overview
Query Planning Center Services data with SQL to analyze worship planning, volunteer scheduling, team assignments, and song usage across services.
Access comprehensive worship planning and volunteer management data from Planning Center Services through Parable's unified SQL interface.
Explore all 63 Services tables with complete field documentation
Simple queries for common worship and volunteer questions
Sophisticated analysis for scheduling optimization and trends
Production-ready queries for dashboards and reports
## What's Available
Planning Center Services data in Parable provides complete access to your worship planning and volunteer management system.
### 📊 Core Capabilities
* **Service Planning** - Access all service plans, series, and scheduling data
* **Volunteer Management** - Track team participation, availability, and engagement
* **Song Analytics** - Monitor song usage, keys, themes, and rotation patterns
* **Team Health** - Analyze team capacity, confirmation rates, and scheduling gaps
* **Resource Tracking** - Manage attachments, chord charts, and media files
* **Multi-Site Coordination** - Synchronize services across campuses
### 🎯 Key Use Cases
* Track volunteer participation and availability
* Identify scheduling conflicts and gaps
* Monitor team health and prevent burnout
* Analyze confirmation and decline patterns
* Coordinate blockout dates and preferences
* Analyze song usage and rotation patterns
* Track key preferences and arrangements
* Monitor theme combinations and flow
* Manage CCLI reporting and copyright
* Optimize service length and composition
* Assess team capacity and coverage
* Identify training needs and gaps
* Track background check compliance
* Monitor team engagement levels
* Plan recruitment strategies
* Analyze service timing and flow
* Track item types and distributions
* Monitor volunteer-to-task ratios
* Measure preparation timelines
* Evaluate multi-service consistency
## Quick Start Examples
### Who's Serving This Sunday?
```sql theme={null}
SELECT
p.full_name,
t.name as team,
pp.team_position_name as role,
pl.title as service,
pp.status
FROM planning_center.services_plan_people pp
JOIN planning_center.services_people p ON pp.person_id = p.person_id
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 pl.sort_date >= CURRENT_DATE
AND pl.sort_date < CURRENT_DATE + INTERVAL '7 days'
ORDER BY pl.sort_date, t.name;
```
### Top Songs This Quarter
```sql theme={null}
SELECT
s.title,
s.author,
COUNT(DISTINCT i.plan_id) as times_used,
MAX(pl.sort_date) as last_used
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 '3 months'
AND i.item_type = 'song'
GROUP BY s.song_id, s.title, s.author
ORDER BY times_used DESC
LIMIT 20;
```
### Volunteer Activity Summary
```sql theme={null}
SELECT
t.name as team,
COUNT(DISTINCT pp.person_id) as volunteers,
COUNT(CASE WHEN pp.status = 'C' THEN 1 END) as confirmed,
COUNT(CASE WHEN pp.status = 'D' THEN 1 END) as declined,
ROUND(AVG(CASE WHEN pp.status = 'C' THEN 100.0 ELSE 0 END), 1) as confirm_rate
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 pl.sort_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY t.name
ORDER BY volunteers DESC;
```
## Available Tables
The Services module includes **54 tables** organized into these categories:
### Core Planning Tables
* `services_service_types` - Service categories and settings
* `services_plans` - Individual service plans
* `services_plan_times` - Service and rehearsal times
* `services_plan_templates` - Reusable service templates
* `services_items` - Service order and elements
* `services_series` - Sermon series information
### People & Teams
* `services_people` - Volunteer and staff records
* `services_teams` - Ministry teams
* `services_team_positions` - Roles within teams
* `services_plan_people` - Who's scheduled when
* `services_schedules` - Individual scheduling records
* `services_person_team_position_assignments` - Role assignments
### Worship Resources
* `services_songs` - Song library
* `services_arrangements` - Keys and arrangements
* `services_attachments` - Files and media
* `services_media` - Video and audio resources
* `services_keys` - Musical key information
* `services_song_schedules` - Song planning data
### Scheduling & Availability
* `services_blockouts` - Unavailability patterns
* `services_blockout_dates` - Specific blocked dates
* `services_blockout_exceptions` - Override dates
* `services_needed_positions` - Unfilled positions
* `services_time_preference_options` - Scheduling preferences
[View Complete Data Model →](/planning-center/services/data-model)
## Integration Benefits
Combine Services data with other Planning Center modules for powerful insights:
### With Check-ins
* Compare volunteer schedules with actual attendance
* Track volunteer family check-in patterns
* Analyze service time effectiveness
### With People
* Access complete member profiles
* Track household serving patterns
* Coordinate family scheduling
### With Giving
* Correlate serving with giving patterns
* Identify engaged members across ministries
* Track volunteer donor overlap
### With Groups
* Find group members serving on teams
* Identify potential volunteers from groups
* Track ministry involvement paths
### With Calendar
* Coordinate facility usage with services
* Manage resource conflicts
* Plan rehearsal spaces
## Best Practices
**Performance Tip**: Index frequently queried columns like `sort_date`, `person_id`, and `team_id` for faster queries.
**Data Freshness**: Services data syncs regularly. Check the `system_updated_at` field for last sync time.
**Privacy**: Always respect volunteer privacy. Limit access to personal information and scheduling details.
## Next Steps
Ready to dive deeper? Explore our comprehensive guides:
1. **[Understand the Data Model](/planning-center/services/data-model)** - Learn about all tables and relationships
2. **[Start with Basic Queries](/planning-center/services/basic-queries)** - Simple SQL for common questions
3. **[Master Advanced Analytics](/planning-center/services/advanced-queries)** - Complex analysis and optimization
4. **[Build Complete Reports](/planning-center/services/reporting-examples)** - Production-ready dashboard queries
***
*Transform your worship planning and volunteer management with data-driven insights from Planning Center Services.*
# Planning Center Services Report Examples
Source: https://docs.getparable.io/planning-center/services/reporting-examples
Production-ready Services reports for worship and volunteer leaders: rotation summaries, coverage gaps, and song usage ready for BI tools.
This guide provides complete SQL queries for building comprehensive worship and volunteer reports. These examples are designed for use in BI tools like Power BI, Tableau, or custom dashboards.
These queries are structured for direct use in reporting tools. They include all necessary joins, calculations, and formatting for professional ministry reports.
## Query Requirements
### Schema Prefix
**IMPORTANT:** All tables in the Planning Center Services module live in the `planning_center` schema. Always prefix table names with `planning_center.` in your reports.
✅ 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
**Avoid adding these filters manually**—RLS already enforces them and redundant predicates can hide data or slow execution:
* ❌ `WHERE tenant_organization_id = 1`
* ❌ `WHERE system_status = 'active'`
Keep your filters focused on scheduling cadence, volunteer status, and worship planning while relying on RLS for tenancy and system status.
## Volunteer Management Dashboard
### Complete Volunteer Overview
```sql theme={null}
-- Comprehensive volunteer metrics for dashboard
WITH volunteer_base AS (
SELECT
p.person_id,
p.full_name,
p.first_name,
p.last_name,
p.photo_thumbnail_url,
p.preferred_max_plans_per_month,
p.archived,
p.passed_background_check,
STRING_AGG(DISTINCT t.name, ', ') as teams,
COUNT(DISTINCT t.team_id) as team_count
FROM planning_center.services_people p
LEFT JOIN planning_center.services_plan_people pp ON p.person_id = pp.person_id
LEFT JOIN planning_center.services_teams t ON pp.team_id = t.team_id
WHERE p.archived = false
GROUP BY p.person_id, p.full_name, p.first_name, p.last_name,
p.photo_thumbnail_url, p.preferred_max_plans_per_month,
p.archived, p.passed_background_check
),
recent_activity AS (
SELECT
pp.person_id,
COUNT(DISTINCT pl.plan_id) as services_last_90_days,
COUNT(CASE WHEN pp.status = 'C' THEN 1 END) as confirmed_last_90,
COUNT(CASE WHEN pp.status = 'D' THEN 1 END) as declined_last_90,
MAX(pl.sort_date) as last_scheduled,
MIN(pl.sort_date) FILTER (WHERE pl.sort_date >= CURRENT_DATE) as next_scheduled
FROM planning_center.services_plan_people pp
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '90 days'
AND pl.sort_date <= CURRENT_DATE + INTERVAL '30 days'
GROUP BY pp.person_id
),
blockout_summary AS (
SELECT
b.person_id,
COUNT(DISTINCT bd.starts_at::date) as blocked_days_next_30
FROM planning_center.services_blockouts b
JOIN planning_center.services_blockout_dates bd ON b.blockout_id = bd.blockout_id
WHERE bd.starts_at::date >= CURRENT_DATE
AND bd.starts_at::date <= CURRENT_DATE + INTERVAL '30 days'
GROUP BY b.person_id
)
SELECT
vb.full_name,
vb.first_name,
vb.last_name,
vb.photo_thumbnail_url,
vb.teams,
vb.team_count,
COALESCE(ra.services_last_90_days, 0) as services_last_90_days,
COALESCE(ra.confirmed_last_90, 0) as confirmed_count,
COALESCE(ra.declined_last_90, 0) as declined_count,
CASE
WHEN ra.services_last_90_days > 0
THEN ROUND(ra.confirmed_last_90::numeric * 100 / ra.services_last_90_days, 1)
ELSE 0
END as confirmation_rate,
ra.last_scheduled,
ra.next_scheduled,
CURRENT_DATE - ra.last_scheduled as days_since_served,
vb.preferred_max_plans_per_month as monthly_limit,
COALESCE(bs.blocked_days_next_30, 0) as unavailable_days,
vb.passed_background_check,
CASE
WHEN ra.services_last_90_days = 0 OR ra.last_scheduled < CURRENT_DATE - INTERVAL '90 days'
THEN 'Inactive'
WHEN ra.services_last_90_days >= 12 THEN 'Very Active'
WHEN ra.services_last_90_days >= 6 THEN 'Active'
WHEN ra.services_last_90_days >= 3 THEN 'Occasional'
ELSE 'Rare'
END as activity_level,
CASE
WHEN ra.next_scheduled IS NOT NULL THEN 'Scheduled'
WHEN bs.blocked_days_next_30 > 15 THEN 'Mostly Unavailable'
WHEN bs.blocked_days_next_30 > 0 THEN 'Partially Available'
ELSE 'Available'
END as current_status
FROM volunteer_base vb
LEFT JOIN recent_activity ra ON vb.person_id = ra.person_id
LEFT JOIN blockout_summary bs ON vb.person_id = bs.person_id
ORDER BY ra.services_last_90_days DESC NULLS LAST, vb.full_name;
```
### Team Roster Report
```sql theme={null}
-- Detailed team roster with positions and availability
--
-- Scheduling history and blockout dates are summarized per person BEFORE the
-- roster join. Joining both directly would multiply every plan row by every
-- blockout row for that person, and on a large Services history that fan-out
-- is enough to make the report time out.
WITH schedule_summary AS (
SELECT
pp.person_id,
pp.team_id,
COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
) as recent_schedules,
STRING_AGG(
DISTINCT pl.short_dates || ' (' || pp.status || ')',
', ' ORDER BY pl.short_dates || ' (' || pp.status || ')'
) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 month'
) as recent_services
FROM planning_center.services_plan_people pp
JOIN planning_center.services_plans pl ON pl.plan_id = pp.plan_id
GROUP BY pp.person_id, pp.team_id
),
blockout_summary AS (
SELECT
person_id,
MAX(starts_at::date) FILTER (
WHERE starts_at::date >= CURRENT_DATE
) as next_blocked_date,
COUNT(*) FILTER (
WHERE starts_at::date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '14 days'
) as blocked_days_next_14
FROM planning_center.services_blockout_dates
GROUP BY person_id
)
SELECT
t.name as team_name,
t.schedule_to as scheduling_type,
tp.name as position,
p.full_name,
p.photo_thumbnail_url,
CASE
WHEN p.passed_background_check = true THEN 'Yes'
WHEN t.secure_team = true THEN 'Required'
ELSE 'N/A'
END as background_check,
COALESCE(ss.recent_schedules, 0) as recent_schedules,
ss.recent_services,
bs.next_blocked_date,
CASE
WHEN COALESCE(bs.blocked_days_next_14, 0) > 7 THEN 'Mostly Unavailable'
WHEN COALESCE(bs.blocked_days_next_14, 0) > 0 THEN 'Partially Available'
ELSE 'Available'
END as two_week_availability
FROM planning_center.services_teams t
JOIN planning_center.services_team_positions tp ON t.team_id = tp.team_id
JOIN planning_center.services_person_team_position_assignments pa
ON tp.team_position_id = pa.team_position_id
JOIN planning_center.services_people p ON pa.person_id = p.person_id
LEFT JOIN schedule_summary ss
ON ss.person_id = p.person_id AND ss.team_id = t.team_id
LEFT JOIN blockout_summary bs ON bs.person_id = p.person_id
WHERE t.archived_at IS NULL
AND p.archived = false
ORDER BY t.name, tp.name, p.full_name;
```
## Worship Planning Analytics
### Song Usage Report
```sql theme={null}
-- Complete song analytics for worship planning
WITH song_metrics AS (
SELECT
s.song_id,
s.title,
s.author,
s.copyright,
s.ccli,
s.themes,
s.hidden,
COUNT(DISTINCT i.plan_id) as total_uses,
COUNT(DISTINCT i.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
) as uses_last_3_months,
COUNT(DISTINCT i.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 year'
) as uses_last_year,
MIN(pl.sort_date) as first_used,
MAX(pl.sort_date) as last_used,
STRING_AGG(DISTINCT a.chord_chart_key, ', ') as keys_used,
AVG(i.length)::INTEGER as avg_length_seconds,
COUNT(DISTINCT st.service_type_id) as service_types_used_in
FROM planning_center.services_songs s
LEFT JOIN planning_center.services_items i ON s.song_id = i.song_id
LEFT JOIN planning_center.services_plans pl ON i.plan_id = pl.plan_id
LEFT JOIN planning_center.services_arrangements a ON i.arrangement_id = a.arrangement_id
LEFT JOIN planning_center.services_service_types st ON pl.service_type_id = st.service_type_id
WHERE i.item_type = 'song' OR i.item_type IS NULL
GROUP BY s.song_id, s.title, s.author, s.copyright, s.ccli, s.themes, s.hidden
),
arrangement_counts AS (
SELECT
song_id,
COUNT(*) as arrangement_count,
STRING_AGG(name || ' (' || chord_chart_key || ')', ', ') as arrangements
FROM planning_center.services_arrangements
WHERE archived_at = '0001-01-01 00:00:00' OR archived_at IS NULL
GROUP BY song_id
)
SELECT
sm.title,
sm.author,
sm.copyright,
sm.ccli,
sm.themes,
CASE WHEN sm.hidden = true THEN 'Hidden' ELSE 'Active' END as status,
COALESCE(sm.total_uses, 0) as total_uses,
COALESCE(sm.uses_last_3_months, 0) as recent_uses,
COALESCE(sm.uses_last_year, 0) as yearly_uses,
sm.first_used,
sm.last_used,
CURRENT_DATE - sm.last_used as days_since_used,
sm.keys_used,
ac.arrangement_count,
ac.arrangements,
sm.avg_length_seconds / 60.0 as avg_length_minutes,
sm.service_types_used_in,
CASE
WHEN sm.last_used IS NULL THEN 'Never Used'
WHEN sm.last_used < CURRENT_DATE - INTERVAL '1 year' THEN 'Not Recently Used'
WHEN sm.uses_last_3_months >= 10 THEN 'High Rotation'
WHEN sm.uses_last_3_months >= 5 THEN 'Regular Rotation'
WHEN sm.uses_last_3_months >= 2 THEN 'Occasional'
ELSE 'Rarely Used'
END as usage_category,
CASE
WHEN sm.last_used < CURRENT_DATE - INTERVAL '2 months'
AND sm.uses_last_year >= 6 THEN 'Consider Scheduling'
WHEN sm.uses_last_3_months >= 8 THEN 'Recently Overused'
ELSE 'Normal'
END as recommendation
FROM song_metrics sm
LEFT JOIN arrangement_counts ac ON sm.song_id = ac.song_id
ORDER BY sm.uses_last_3_months DESC, sm.title;
```
### Service Flow Analysis
```sql theme={null}
-- Analyze service structure and timing patterns
WITH service_details AS (
SELECT
st.name as service_type,
pl.plan_id,
pl.title,
pl.series_title,
pl.sort_date,
pl.total_length / 60.0 as total_minutes,
pl.plan_people_count as volunteers,
COUNT(i.item_id) as item_count,
COUNT(CASE WHEN i.item_type = 'song' THEN 1 END) as song_count,
COUNT(CASE WHEN i.item_type = 'header' THEN 1 END) as header_count,
COUNT(CASE WHEN i.item_type = 'media' THEN 1 END) as media_count,
SUM(CASE WHEN i.item_type = 'song' THEN i.length ELSE 0 END) / 60.0 as music_minutes,
SUM(CASE WHEN i.item_type != 'song' THEN i.length ELSE 0 END) / 60.0 as non_music_minutes,
STRING_AGG(
CASE WHEN i.item_type = 'header' THEN i.title END,
' > ' ORDER BY i.sequence
) as section_flow
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_items i ON pl.plan_id = i.plan_id
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY st.name, pl.plan_id, pl.title, pl.series_title, pl.sort_date,
pl.total_length, pl.plan_people_count
)
SELECT
service_type,
title,
series_title,
sort_date,
total_minutes,
volunteers,
item_count,
song_count,
header_count,
media_count,
ROUND(music_minutes, 1) as worship_minutes,
ROUND(non_music_minutes, 1) as other_minutes,
ROUND(music_minutes * 100 / NULLIF(total_minutes, 0), 1) as worship_percentage,
ROUND(volunteers::numeric / NULLIF(item_count, 0), 1) as volunteers_per_item,
section_flow,
CASE
WHEN total_minutes < 45 THEN 'Short Service'
WHEN total_minutes BETWEEN 45 AND 75 THEN 'Standard Service'
WHEN total_minutes BETWEEN 75 AND 90 THEN 'Extended Service'
ELSE 'Long Service'
END as service_length_category,
CASE
WHEN song_count = 0 THEN 'No Music'
WHEN song_count <= 3 THEN 'Light Music'
WHEN song_count <= 5 THEN 'Standard Music'
ELSE 'Music Heavy'
END as music_emphasis
FROM service_details
ORDER BY sort_date DESC;
```
## Ministry Effectiveness Metrics
### Volunteer Engagement Score
This is a custom volunteer scoring example. If you are looking for the
engagement score your team sees in Parable, read
[Engagement Scoring](/planning-center/engagement-scoring) first.
```sql theme={null}
-- Calculate comprehensive engagement scores for volunteers
WITH engagement_metrics AS (
SELECT
p.person_id,
p.full_name,
-- Participation metrics
COUNT(DISTINCT pp.plan_id) as total_scheduled,
COUNT(DISTINCT pp.plan_id) FILTER (WHERE pp.status = 'C') as confirmed_services,
COUNT(DISTINCT pp.plan_id) FILTER (WHERE pp.status = 'D') as declined_services,
COUNT(DISTINCT t.team_id) as teams_serving,
COUNT(DISTINCT DATE_TRUNC('month', pl.sort_date)) as months_active,
-- Timing metrics
AVG(EXTRACT(DAYS FROM pl.sort_date - pp.notification_sent_at)) as avg_notice_days,
AVG(EXTRACT(DAYS FROM pl.sort_date - pp.status_updated_at)) as avg_response_days,
-- Reliability metrics
COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pp.status = 'C' AND pp.status_updated_at > pl.sort_date - INTERVAL '7 days'
) as early_confirmations,
-- Recent activity
MAX(pl.sort_date) as last_served,
MIN(pl.sort_date) FILTER (WHERE pl.sort_date >= CURRENT_DATE) as next_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 '6 months'
AND p.archived = false
GROUP BY p.person_id, p.full_name
HAVING COUNT(DISTINCT pp.plan_id) >= 3 -- Minimum activity threshold
)
SELECT
full_name,
total_scheduled,
confirmed_services,
declined_services,
ROUND(confirmed_services::numeric * 100 / NULLIF(total_scheduled, 0), 1) as confirmation_rate,
teams_serving,
months_active,
ROUND(total_scheduled::numeric / NULLIF(months_active, 0), 1) as services_per_month,
ROUND(avg_notice_days, 1) as avg_notice_days,
ROUND(avg_response_days, 1) as avg_response_days,
ROUND(early_confirmations::numeric * 100 / NULLIF(confirmed_services, 0), 1) as early_confirm_rate,
last_served,
next_scheduled,
-- Calculate engagement score (0-100)
ROUND(
(
-- Confirmation rate (40% weight)
(confirmed_services::numeric / NULLIF(total_scheduled, 0) * 40) +
-- Activity consistency (30% weight)
(LEAST(months_active / 6.0, 1.0) * 30) +
-- Response time (20% weight)
(CASE
WHEN avg_response_days <= 2 THEN 20
WHEN avg_response_days <= 5 THEN 15
WHEN avg_response_days <= 7 THEN 10
ELSE 5
END) +
-- Team diversity (10% weight)
(LEAST(teams_serving / 3.0, 1.0) * 10)
), 1
) as engagement_score,
CASE
WHEN confirmed_services::numeric / NULLIF(total_scheduled, 0) >= 0.9
AND months_active >= 5 THEN 'Champion'
WHEN confirmed_services::numeric / NULLIF(total_scheduled, 0) >= 0.75
AND months_active >= 3 THEN 'Reliable'
WHEN confirmed_services::numeric / NULLIF(total_scheduled, 0) >= 0.6 THEN 'Developing'
ELSE 'Needs Support'
END as engagement_category
FROM engagement_metrics
ORDER BY engagement_score DESC;
```
### Team Health Dashboard
```sql theme={null}
-- Comprehensive team health metrics
WITH team_stats AS (
SELECT
t.team_id,
t.name,
t.rehearsal_team,
t.secure_team,
COUNT(DISTINCT pp.person_id) as active_members,
COUNT(DISTINCT pp.plan_id) as total_schedules,
COUNT(DISTINCT pp.plan_id) FILTER (WHERE pp.status = 'C') as confirmed_schedules,
COUNT(DISTINCT pp.plan_id) FILTER (WHERE pp.status = 'D') as declined_schedules,
COUNT(DISTINCT np.plan_id) as plans_with_needs,
SUM(np.quantity) as total_positions_needed
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, t.rehearsal_team, t.secure_team
),
position_coverage AS (
SELECT
t.team_id,
COUNT(DISTINCT tp.team_position_id) as position_count,
COUNT(DISTINCT pa.person_id) as qualified_people,
STRING_AGG(DISTINCT tp.name, ', ') as positions
FROM planning_center.services_teams t
LEFT 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
),
recent_trends AS (
SELECT
pp.team_id,
COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 month'
) as schedules_last_month,
COUNT(DISTINCT pp.person_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '1 month'
) as people_last_month
FROM planning_center.services_plan_people pp
JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
GROUP BY pp.team_id
)
SELECT
ts.name as team_name,
CASE WHEN ts.rehearsal_team THEN 'Yes' ELSE 'No' END as rehearsal_required,
CASE WHEN ts.secure_team THEN 'Yes' ELSE 'No' END as background_check_required,
ts.active_members,
pc.position_count,
pc.qualified_people,
ROUND(pc.qualified_people::numeric / NULLIF(pc.position_count, 0), 1) as people_per_position,
ts.total_schedules as schedules_3_month,
rt.schedules_last_month,
rt.people_last_month,
ts.confirmed_schedules,
ROUND(ts.confirmed_schedules::numeric * 100 / NULLIF(ts.total_schedules, 0), 1) as confirmation_rate,
ts.declined_schedules,
ts.plans_with_needs,
ts.total_positions_needed,
ROUND(ts.total_positions_needed::numeric / NULLIF(ts.plans_with_needs, 0), 1) as avg_needs_per_plan,
pc.positions,
-- Health score calculation
CASE
WHEN ts.confirmed_schedules::numeric / NULLIF(ts.total_schedules, 0) >= 0.9
AND pc.qualified_people >= pc.position_count * 2
AND ts.plans_with_needs = 0 THEN 'Excellent'
WHEN ts.confirmed_schedules::numeric / NULLIF(ts.total_schedules, 0) >= 0.8
AND pc.qualified_people >= pc.position_count * 1.5 THEN 'Good'
WHEN ts.confirmed_schedules::numeric / NULLIF(ts.total_schedules, 0) >= 0.7
AND pc.qualified_people >= pc.position_count THEN 'Fair'
ELSE 'Needs Attention'
END as health_status,
CASE
WHEN ts.plans_with_needs > ts.total_schedules * 0.2 THEN 'Understaffed - Recruit More'
WHEN pc.qualified_people < pc.position_count * 1.5 THEN 'Low Depth - Train Backups'
WHEN ts.confirmed_schedules::numeric / NULLIF(ts.total_schedules, 0) < 0.7 THEN 'Low Engagement - Connect with Team'
ELSE 'Healthy'
END as primary_recommendation
FROM team_stats ts
LEFT JOIN position_coverage pc ON ts.team_id = pc.team_id
LEFT JOIN recent_trends rt ON ts.team_id = rt.team_id
ORDER BY ts.active_members DESC;
```
## Executive Summary Report
### Ministry Overview Dashboard
```sql theme={null}
-- High-level metrics for leadership
WITH summary_metrics AS (
SELECT
-- People metrics
(SELECT COUNT(*) FROM planning_center.services_people WHERE archived = false) as total_volunteers,
(SELECT COUNT(DISTINCT person_id)
FROM planning_center.services_plan_people pp
JOIN planning_center.services_plans p ON pp.plan_id = p.plan_id
WHERE p.sort_date >= CURRENT_DATE - INTERVAL '3 months') as active_volunteers,
-- Team metrics
(SELECT COUNT(*) FROM planning_center.services_teams WHERE archived_at IS NULL) as total_teams,
-- Service metrics
(SELECT COUNT(*)
FROM planning_center.services_plans
WHERE sort_date >= CURRENT_DATE - INTERVAL '3 months') as services_last_quarter,
(SELECT COUNT(*)
FROM planning_center.services_plans
WHERE sort_date >= CURRENT_DATE
AND sort_date <= CURRENT_DATE + INTERVAL '1 month') as services_next_month,
-- Song metrics
(SELECT COUNT(*) FROM planning_center.services_songs WHERE hidden = false) as active_songs,
(SELECT COUNT(DISTINCT song_id)
FROM planning_center.services_items i
JOIN planning_center.services_plans p ON i.plan_id = p.plan_id
WHERE p.sort_date >= CURRENT_DATE - INTERVAL '3 months'
AND i.item_type = 'song') as songs_used_recently,
-- Scheduling metrics
(SELECT AVG(plan_people_count)
FROM planning_center.services_plans
WHERE sort_date >= CURRENT_DATE - INTERVAL '3 months') as avg_volunteers_per_service,
(SELECT SUM(quantity)
FROM planning_center.services_needed_positions np
JOIN planning_center.services_plans p ON np.plan_id = p.plan_id
WHERE p.sort_date >= CURRENT_DATE
AND p.sort_date <= CURRENT_DATE + INTERVAL '2 weeks') as open_positions_two_weeks
)
SELECT
total_volunteers,
active_volunteers,
ROUND(active_volunteers::numeric * 100 / NULLIF(total_volunteers, 0), 1) as volunteer_engagement_rate,
total_teams,
services_last_quarter,
services_next_month,
active_songs,
songs_used_recently,
ROUND(songs_used_recently::numeric * 100 / NULLIF(active_songs, 0), 1) as song_utilization_rate,
ROUND(avg_volunteers_per_service, 1) as avg_volunteers_per_service,
COALESCE(open_positions_two_weeks, 0) as urgent_scheduling_needs,
CASE
WHEN open_positions_two_weeks > 10 THEN 'Critical - Many Open Positions'
WHEN open_positions_two_weeks > 5 THEN 'Warning - Some Gaps'
ELSE 'Healthy - Well Staffed'
END as scheduling_status
FROM summary_metrics;
```
## Export-Ready Formats
### CSV Export for Volunteer Contact
```sql theme={null}
-- Volunteer contact list for mail merge or communication
SELECT
p.full_name,
p.first_name,
p.last_name,
STRING_AGG(DISTINCT t.name, '; ') as teams,
COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
) as services_last_3_months,
MAX(pl.sort_date) FILTER (
WHERE pl.sort_date < CURRENT_DATE
) as last_served,
MIN(pl.sort_date) FILTER (
WHERE pl.sort_date >= CURRENT_DATE
) as next_scheduled,
CASE
WHEN p.archived = true THEN 'Inactive'
WHEN COUNT(DISTINCT pp.plan_id) FILTER (
WHERE pl.sort_date >= CURRENT_DATE - INTERVAL '3 months'
) = 0 THEN 'No Recent Activity'
ELSE 'Active'
END as status
FROM planning_center.services_people p
LEFT JOIN planning_center.services_plan_people pp ON p.person_id = pp.person_id
LEFT JOIN planning_center.services_plans pl ON pp.plan_id = pl.plan_id
LEFT JOIN planning_center.services_teams t ON pp.team_id = t.team_id
WHERE p.archived = false
GROUP BY p.person_id, p.full_name, p.first_name, p.last_name, p.archived
ORDER BY p.last_name, p.first_name;
```
## Tips for Report Building
**Performance**: For large datasets, consider creating materialized views for complex calculations that don't need real-time updates.
**Visualization**: These queries return data optimized for charts. Use the categorical fields for grouping and numerical fields for metrics.
**Privacy**: Always respect privacy when sharing reports. Consider removing personally identifiable information for broad distribution.
## Next Steps
* Import these queries into your BI tool of choice
* Set up automated refresh schedules for dashboards
* Create drill-down capabilities from summary to detail views
* Combine with other Planning Center modules for comprehensive insights
* Export key metrics for leadership meetings and planning sessions
# Quickstart
Source: https://docs.getparable.io/quickstart
Create your organization, connect Planning Center, and get your first insights
## Get Parable Running
Setting up Parable takes about ten minutes of your time. Most of the elapsed
time is the first Planning Center sync, which runs in the background while you
explore.
## Before You Start
You'll authorize with an account that has **Organizational Admin**
permissions — plus **Giving Administrator** if you want giving data
The 30-day trial doesn't ask for payment details
You do **not** need to bring your own database. Parable hosts the warehouse
and provisions a read-only connection for you once your data has synced.
## Setup in Four Steps
### Step 1: Create Your Organization
Tell us about your church so we can size and contextualize your data.
1. **Organization Name** — your church's name
2. **Mailing Address** — street, city, state/province, and postal code
* **Denomination**
* **Average Weekly Attendance**
* **Age of Organization**
* **Number of Staff Members**
These help benchmark your data against similar churches and set sensible
defaults for engagement scoring and metrics.
### Step 2: Connect Planning Center
**People** is required — it's the backbone every other module links to.
Then pick any of:
| App | What it brings in |
| ------------- | ---------------------------------------------------- |
| Giving | Donations, funds, pledges, recurring gifts, batches |
| Check-Ins | Check-ins, events, locations, headcounts |
| Groups | Groups, memberships, events, attendance, RSVPs |
| Services | Plans, teams, positions, scheduling, songs |
| Calendar | Events, resources, room bookings, approvals |
| Registrations | Signups, attendees, waitlists, selection types |
| Publishing | Series, episodes, speakers, watch counts |
| Home | Organization hub (authorization only — no data sync) |
Connect everything you use. Cross-module questions — *do people who serve
also give?* — only work when both modules are synced.
You'll be redirected to Planning Center to approve access. Make sure your
account has **Organizational Admin**, and **Giving Administrator** if you
selected Giving.
Give the integration a nickname if you'll connect more than one Planning
Center organization.
### Step 3: Start Your Free Trial
Thirty days of full access, no credit card required. You can add billing details
later under **Settings → Billing**.
### Step 4: Let the First Sync Run
Parable begins importing immediately. The first full sync pulls your complete
history and can take anywhere from a few minutes to a few hours depending on
your church's size — it runs in the background, and you can start exploring as
data lands.
After the initial import, Parable keeps everything current automatically. Sync
status is visible under **Settings → Sync**.
## What to Do First
On the Overview page, ask something in plain language — *"how many first-time
guests did we have last month?"* — and get an answer, chart, or report back
Open any person to see their engagement score, giving pattern, attendance
history, and group involvement in one profile
Assemble tiles for the metrics your leadership meeting actually needs
Save a report and have it emailed on a recurring schedule, or export it to
PDF or CSV
## Going Deeper With SQL
Two ways to query your data directly:
Open **SQL Editor** in the sidebar. It includes schema autocomplete, query
history, saved queries, and CSV export — no setup required.
Go to **Settings → Database**, create a connection, and Parable generates a
read-only PostgreSQL connection string for you.
The credentials stay available — press **Connect** on the connection to
see the password and connection string again at any time.
See [Accessing Your Data](/access-data) for client setup.
### Your First Queries
Every Planning Center table lives in the `planning_center` schema, and
relationships live in separate `*_relationships` tables rather than foreign-key
columns.
```sql theme={null}
-- Active people, by membership status
SELECT
COALESCE(membership, 'Unspecified') as membership,
COUNT(*) as people
FROM planning_center.people_people
WHERE status = 'active'
GROUP BY COALESCE(membership, 'Unspecified')
ORDER BY people DESC;
```
```sql theme={null}
-- Weekly check-in trend over the last 12 weeks
SELECT
DATE_TRUNC('week', c.created_at) as week,
COUNT(DISTINCT cr.relationship_id) as unique_attendees,
COUNT(*) as check_ins
FROM planning_center.checkins_check_ins c
JOIN planning_center.checkins_check_ins_relationships cr
ON cr.check_in_id = c.check_in_id
AND cr.relationship_type = 'Person'
WHERE c.created_at >= CURRENT_DATE - INTERVAL '12 weeks'
GROUP BY DATE_TRUNC('week', c.created_at)
ORDER BY week DESC;
```
```sql theme={null}
-- Giving totals by fund, this year
SELECT
f.name as fund,
COUNT(DISTINCT d.donation_id) as gifts,
SUM(des.amount_cents) / 100.0 as total_dollars
FROM planning_center.giving_donations d
JOIN planning_center.giving_donations_relationships dr
ON dr.donation_id = d.donation_id
AND dr.relationship_type = 'Designation'
JOIN planning_center.giving_designations des
ON des.designation_id = dr.relationship_id
JOIN planning_center.giving_designations_relationships desr
ON desr.designation_id = des.designation_id
AND desr.relationship_type = 'Fund'
JOIN planning_center.giving_funds f
ON f.fund_id = desr.relationship_id
WHERE d.payment_status = 'succeeded'
AND d.received_at >= DATE_TRUNC('year', CURRENT_DATE)
GROUP BY f.name
ORDER BY total_dollars DESC;
```
Don't filter on `tenant_organization_id` or `system_status` — row-level
security applies both automatically, and adding them yourself can hide rows or
slow the query down.
## Where to Go Next
* [Accessing Your Data](/access-data) — connect PGAdmin, TablePlus, Power BI, or Tableau
* [Planning Center Overview](/planning-center/index) — how the synced schema is organized
* [Engagement Scoring](/planning-center/engagement-scoring) — how scores, tiers, and warning lights work
* [Giving Stages](/guides/giving-engagement/giving-stages) — how donor patterns are classified
**Need help?** Join the
[community Slack](https://join.slack.com/t/theparablecommunity/shared_invite/zt-3btkowr37-1kNbjthQJ6EVVty2tm2KNQ)
or email [michael@getparable.io](mailto:michael@getparable.io).
# Roadmap
Source: https://docs.getparable.io/roadmap
Upcoming and in-progress product work for Parable
Upcoming and in-progress product work for Parable.
This page tracks upcoming and in-progress work only. Once a feature ships, it
moves to the [changelog](/changelog) and is removed from this roadmap.
## Upcoming and In Progress
A focused workspace for pastors and admins to see people who need follow-up,
track touchpoints, and coordinate care. Available in early access to
selected organizations while we refine it.
* Surface first-time guests, care follow-ups, and people who need attention
* Assign people to leaders and keep pastoral touchpoints on a shared timeline
* Move teams from scattered notes to one care workflow, with notes that can
flow back to Planning Center
Clearer visibility into syncs, automation status, suggested actions, and
items that need review before Parable updates Planning Center.
* Show which workflows are healthy, delayed, or need attention
* Queue suggested updates for human approval before write-back
* Give admins an operational view of background work
Making report creation more conversational and iterative.
* Richer file and PDF context for report generation
* Easier refinement of previously generated reports
* Clearer separation between advising and producing a finished report
Helping churches define the next step for each person and see who is moving
along it.
* Define pathway stages that reflect how your church actually disciples
* See who is stalled between steps and who is ready for the next one
* Connect pathway progress to engagement scoring and follow-up
Turning insights into action by triggering follow-up communication from the
patterns Parable already detects.
* Trigger sequences from giving patterns, engagement changes, and first visits
* Keep staff in the loop with review before anything sends
* Measure whether follow-up actually changed the outcome
## Recently Shipped
Looking for something that used to be on this list? These have shipped:
* **Weekly Digest Email** — a Monday post-sync summary of what changed and who
needs attention
* **Weekly Scoreboard & Goals** — custom metrics with goal tracking, campus
filters, and trend comparisons under **Settings → Metrics**
* **Engagement Scoring** — per-person scores, tiers, and warning lights, with
optional write-back to Planning Center. See
[Engagement Scoring](/planning-center/engagement-scoring)
* **Dashboard Sharing** — publish dashboards by link and manage collaborator
access
See the [changelog](/changelog) for the full history.