Skip to main content

Bringing denormalized and rollup tables into Holistics

Question​

Question

"My dbt layer already handles cleaning, joining, and some aggregation, so I've got wide, pre-joined tables (denormalized) and a few rollup/summary tables (pre-aggregated) built for performance.

What's the best way to bring these into Holistics: model on top of them directly, or break everything back into normalized, fact and dimension tables?"

Answer​

This is a common question when working with bringing data tables into BI/analytics tools: choosing between pre-joined/pre-aggregated tables and normalized tables.

Normally for most analytics tools, it's a real tradeoff:

Pre-joined / pre-aggregated tablesNormalized (fact + dimension) tables
Query performanceFast, nothing to join or aggregate at query timeSlower, Holistics joins and aggregates at query time
Self-service flexibilityRigid: only the dimensions baked in at build time are queryable, so it limits the range of questions end users can askFlexible: any dimension can be joined in, so end users aren't limited to what was pre-selected
MaintainabilityDimension values are duplicated in every such table (flatten_orders, flatten_users, etc.), so they can drift out of sync when something changes upstreamDimensions live in one place, so there's a single source of truth to update

In Holistics, you don't have to choose.

Holistics allows you to use normalized data models for self-serve flexiblity, while utilizing the pre-joined, pre-aggregated tables behind the scene for better performance. The engine automatically serves matching queries from those tables underneath, so end users get a clean, flexible model without giving up speed.

For normalized table modeling patterns, see Star schema for a single fact table, or Galaxy schema once you have several sharing dimensions.

Register your table as a pre-aggregate​

Use ExternalPersistence to point Holistics at your existing table. This works the same way whether your table is pre-joined (flattened, but at the same grain as the fact) or pre-aggregated (rolled up to a coarser grain, like daily or regional totals): either way, Holistics treats it as a precomputed table it can route matching queries to.

Say your normalized model is built on these source tables:

Table tickets {
id integer [pk]
agent_id integer
customer_id integer
created_at datetime
}

Table agents {
id integer [pk]
name varchar
}

Table customers {
id integer [pk]
tier varchar
}

Pre-joined (denormalized) table​

Your existing pre-joined table might look like this, one row per ticket with the agent and customer attributes already flattened in:

Table flatten_tickets {
ticket_id integer [pk]
agent_name varchar
customer_tier varchar
created_at datetime
}

Register it as a pre-aggregate on your fct_tickets model. Since flatten_tickets is at the same grain as fct_tickets (one row per ticket, no precomputed counts), map ticket_id as a dimension instead of a measure. Holistics then computes total_tickets as COUNT(ticket_id) at query time:

Dataset support {
models: [fct_tickets, dim_agents, dim_customers]

pre_aggregates: {
agg_tickets_flat: PreAggregate {
dimension ticket_id {
for: r(fct_tickets.id)
}
dimension agent_name {
for: r(dim_agents.name)
}
dimension customer_tier {
for: r(dim_customers.tier)
}
dimension created_at {
for: r(fct_tickets.created_at)
type: 'datetime'
}

persistence: ExternalPersistence {
table_name: 'flatten_tickets'
}
}
}
}

Pre-aggregated (rollup) table​

A pre-aggregated (rollup) table works the same way, just at a coarser grain. Your rollup table might look like this, one row per agent per day:

Table daily_agent_summary {
ticket_date date
agent_name varchar
total_tickets integer
}
Dataset support {
models: [fct_tickets, dim_agents]

pre_aggregates: {
agg_tickets_daily_agent: PreAggregate {
dimension agent_name {
for: r(dim_agents.name)
}
dimension ticket_date {
for: r(fct_tickets.created_at)
type: 'date'
}
measure total_tickets {
for: r(fct_tickets.id)
aggregation_type: 'count'
}

persistence: ExternalPersistence {
table_name: 'daily_agent_summary'
}
}
}
}

Thanks to dimension awareness and join awareness, Holistics automatically routes matching queries to your existing table instead of joining or aggregating at query time. (The pre-aggregate's dimension and measure names just need to match your table's column names.)

The result: end users and other datasets see a clean, reusable, normalized model. Underneath, eligible queries transparently hit your fast, pre-joined or pre-aggregated table. You get the flexibility of normalized modeling and the performance of your existing tables, without picking one over the other.

Additional resources​


Open Markdown
Let us know what you think about this document :)