Thin dimensional spine for multi-fact event analysis
When to use this pattern
Use this pattern when:- You have many event tables sharing common dimensions like
user_id. - Analysts want a single Topic for analysis across multiple event streams.
- Queries typically touch only a subset of those event streams.
- Performance and predictable SQL generation are priorities.
How to model thin spine tables
A common approach to multi-fact analysis is to model a massive join ofcalendar x users x all event tables. This quickly leads to fanout, slow queries, and unpredictable SQL. Instead, you can model a thin dimensional spine — a table where each row represents the existence of activity, not the details of the activity itself.
Instead of this:
Avoid this pattern, which joins everything together
Use this pattern, which creates a thin spine of activity
dim__user_event_spine_thin. This table contains one row per (event_date_day, user_id, event_type) combination that actually occurred:
| user_id | event_date_day | event_type |
|---|---|---|
| 101 | 2025-01-10 | login |
| 101 | 2025-01-10 | session_start |
| 101 | 2025-01-10 | ad_impression |
| 204 | 2025-01-10 | iap_purchase |
Modeling a secondary date spine
In addition to the thin spine, you should maintain a date-only spine table (dim__date_spine) for calendar descriptors. This gives you a clean place for all calendar fields without bloating the thin spine.
To build it:
- Fields - Include attributes like
is_weekday,is_us_federal_holiday, or fiscal calendar fields. - Join logic - Join to the topic on:
Storage considerations
While the spine is logically thin, it can still become large in absolute storage terms. Keep these factors in mind:- Row counts - Each (
user,time grain,event_type) combination produces a row - Granularity - Hourly (or finer) spines significantly multiply row counts
- Pruning - Pruning helps query performance, but not storage footprint
- Default to daily granularity unless finer-grain analysis is truly required
- Use separate spines for different grains (e.g., a daily user spine and an hourly session spine)
- Be deliberate about which event types you include in the spine
Spine materialization benefits
You may wonder whether it’s worth materializing the spine given its potential size. While the spine can grow large, the cost is a known and bounded trade-off:Build-time cost
The spine intentionally shifts cost from query-time to build-time
Reduce effective scan size
Pruning makes the effective scan much smaller than the full table size
Improve predictability
CTE-only patterns can complicate cohort analysis and reusability
Semantic stability
The spine creates a stable semantic surface for downstream analysis
Build and model the spine with dbt and Omni
In this section, we’ll walk you through how to build and materialize the thin spine table using dbt and then model it in Omni. In this guide, we’re using the following event tables:- Engagement -
event__login,event__session_start - Monetization and ads -
event__iap_purchase,event__ad_impression - Shared fields - All event tables share
user_idandevent_ts(timestamp)
Requirements
To follow along, you’ll need:Build a generate_event_spine_from_models dbt macro
First, you’ll build a
generate_event_spine_from_models macro in dbt. This pattern uses the macro to derive event_type from model names and emit a UNION ALL spine query.The macro expects:- Event models are named like
event__login,event__iap_purchase, etc. event_typeis the suffix afterevent__.- Each event model includes
event_tsanduser_idcolumns, or you override these via arguments
generate_event_spine_from_models macro
Build an incremental dim__user_event_spine_thin dbt model
The next step is to build a
dim__user_event_spine_thin model in dbt.The spine model is incremental and supports two modes:- Append-only - Only inserts new spine rows beyond what already exists
- Merge with a 7-day lookback window - Recomputes the most recent 7 days and merges into the target
dim__user_event_spine_thin model
Build the topic in Omni
Once the spine is built, configure your Omni topic to use it as the base view. This topic uses the secondary date spine for calendar descriptors and enforces a date filter for safety.
user_activity.topic file
Curate the topic fields
To make sure users filter consistently on the date spine, hide timestamps and non-common dimensions within the event views:
Snippet of user_activity.topic
Define relationships
The core philosophy of this pattern is to keep the thin spine minimal — it contains only join keys,
event_type, and dates. All other metadata is joined in from external views.There are two key architectural decisions to understand:- Prevent unnecessary symmetric aggregation - Event relationships are explicitly set to
one_to_oneeven though the actual cardinality isone_to_many. This prevents Omni from unnecessarily employing symmetric aggregation. - Minimal base - Many teams hide most or all dimensions on the thin spine so users primarily interact with date fields from the date spine, attributes from
attr_tables, and measures fromevent_views.
Omni Relationships file
Build an example analysis
With the spine materialized and the Omni model built, you can try out what you’ve created by building an analysis. In this example, you’ll build a cross-event analysis that looks at
Week x Country with multi-event measures.In an Omni workbook, create a query with the following configuration:- Dimensions -
dim__date_spine.date_day(grouped by week) andattr__users_profile.country - Measures -
event__login.login_count,event__session_start.session_count,event__ad_impression.ad_impression_count, andevent__iap_purchase.iap_revenue - Derived metric -
avg_logins_per_user, calculated by dividing login count by spine-level distinct users
Optional: Add new event streams
This pattern is designed to make adding new event types mechanical and low-risk. To add a new event stream:
- dbt - Add the event model name to
event_modelsin the spine SQL - Omni topic - In the Omni topic, add
event__new_event: {}to thejoinssection - Pruning - Add a
case when {{ event__new_event.in_query }} then 'new_event' endline toalways_where_sql. - Relationships - In the Omni model’s relationships file, add a conditional
one_to_onerelationship join matching onevent_type,user_id, and date grain
Best practices for modeling spines in Omni
Now that you’ve walked through modeling spines in Omni, we have a few tips to reduce surprises in aggregation and filtering. Keep the following in mind when modeling in Omni.General tips
- Hide non-common dimensions - Expose only curated measures and optionally timestamps for drilling
- Prefer curated metrics - Instead of raw fields like
purchase_amount, expose measures likeiap_revenueorpayer_count - Keep cohort-defining fields in attribute space - Put cohort keys in
attr__tables so they behave consistently across event streams
Apply topic best practices
As your model grows, topics can become difficult to use if the field list gets too large or too heterogeneous. Keep these guidelines in mind:- Focus over mega-topics - Prefer multiple focused topics (e.g.,
engagement,monetization) over a single mega-topic - User-facing product - Treat topics as user-facing products with coherent scopes
- Spine reusability - The thin spine is infrastructure — you can reuse it across multiple topics
Use filtered measures for event-specific breakdowns
Instead of exposing event-specific dimensions directly, create curated filtered measures. For example, ifevent__login has a login_method dimension, you can create specific measures for counts of Google or iCloud logins:
event__login.view (illustrative)
Build a cross-event analysis
With this pattern in place, you can build cross-event analyses like a weekly report by country. You would use:- Dimensions -
dim__date_spine.date_day(grouped by week) andattr__users_profile.country - Measures -
event__login.login_count,event__session_start.session_count,event__ad_impression.ad_impression_count, andevent__iap_purchase.iap_revenue - Derived metrics - For example,
avg_logins_per_usercalculated by dividing login count by spine-level distinct users