Skip to content

Metric Filters Inherit Parent Query Granularity#1965

Open
wtremml18 wants to merge 4 commits intodbt-labs:mainfrom
wtremml18:main
Open

Metric Filters Inherit Parent Query Granularity#1965
wtremml18 wants to merge 4 commits intodbt-labs:mainfrom
wtremml18:main

Conversation

@wtremml18
Copy link

Summary

This PR makes metric filters time-aware by allowing metric_time in filter group_by and wiring that through MetricFlow’s resolution pipeline. This addresses a real analytics gap where filters were applied “all‑time” even when the parent query is time‑bucketed.

This is an update to a previous draft pr (#1658) and resolves this issue (#1659)

Example use case (now supported):

{{ Metric('filter_metric', group_by=['klaviyo_account_id','metric_time']) }} > 0

When the query uses --group-by metric_time__month, the filter is evaluated per month instead of all‑time.

Motivation / User Impact

Without this, queries like “ARR from accounts sending push in that month” were incorrectly calculated as “ARR from accounts that ever sent push.” This impacts any metric filtered by a cohort or event metric when the query is time‑bucketed.

Key Changes

  • Metric filter group_by parsing now supports metric_time.
  • Filter join alignment: when metric_time is present, the filter subquery joins on the parent query’s time grain.
  • Validation enforcement: metric filter group_by accepts exactly one non‑metric_time item, with optional metric_time. Invalid inputs are rejected with a clear error.

Behavior (Expected)

  • If metric_time is in the filter, the filter is evaluated at the query grain (day/week/month/etc.).
  • If the query has no time dimension, the filter’s metric_time is ignored (backward compatible).
  • If the filter metric doesn’t support the query’s time grain, the query fails at resolution time with a clear error.

Edge Cases Tested (Local Project)

These cases were exercised with a real project (ns_push_and_in_app_messaging) to validate behavior:

  1. Query daily, filter metric only monthly
    • Changed filter_metric to monthly-only time grain.
    • Query --group-by metric_time__day fails at query resolution. ❌ intended outcome
  2. Query group-by dimension only on base metric (not filter metric)
    • Query --group-by pk_parent_model__parent_dimension succeeds.
    • Filter time is ignored because there is no query time dimension. ✅
  3. Query group-by dimension only on filter metric (not base metric)
    • Query --group-by pk_filter_model__filter_only_dimension fails: no join path. ❌ intended outcome
  4. Filter with extra group_by item
    • Filter group_by includes klaviyo_account_id, metric_time, plus another dimension.
    • Query fails with: “Metric filters require exactly one non‑metric_time group by item.”
  5. Filter specifies metric_time__month but query is daily
    • Query fails at resolution (filter metric only supports month). ❌ intended outcome
  6. Filter entity not reachable from base metric
    • Filter group_by uses filter_only_dimension.
    • Query fails at resolution. ❌ intended outcome
  7. Query entity exists on base metric only
    • Temporarily added parent_only_entity_id entity to base semantic model.
    • Query succeeds; filter still resolves at account/time. ✅
  8. Query entity exists on filter metric only
    • Query --group-by filter_only_dimension fails (no join path). ❌ intended outcome

API Implications

Queryable time grains should effectively be intersected with the filter metric’s time grain. If the API exposes queryable_time_granularities, it should not advertise grains that the filter metric can’t support (or queries will fail at resolution).

Testing

  • hatch run dev-env:pytest tests_metricflow/query_rendering/test_metric_filter_rendering.py
  • hatch run dev-env:pytest tests_metricflow/dataflow/builder/test_dataflow_plan_builder.py -k metric_filter
  • hatch run dev-env:pytest tests_metricflow/integration/query_output/test_metric_filter_output.py
  • DBT_PROJECT_DIR=~/dbt DBT_PROFILES_DIR="$HOME/.dbt" hatch run dev-env:dbt parse (from metricflow/dbt-metricflow)
  • DBT_PROJECT_DIR=~/dbt DBT_PROFILES_DIR="$HOME/.dbt" hatch run dev-env:mf query --metrics ns_push_and_in_app_messaging --group-by metric_time__month --explain --limit 5 (from metricflow/dbt-metricflow)

Risks / Considerations

  • Join complexity: time‑aligned filters add join keys and may increase query cost. This is expected and correct for time‑bounded semantics.
  • Fan‑out avoidance: enforcement of a single non‑metric_time group_by protects against ambiguous or multi‑hop joins.

Notes

  • The metricflow, metricflow-semantics, and dbt-metricflow dev envs use a local dbt-semantic-interfaces checkout (editable). Dependency warnings are expected due to pin differences.
  • dbt parse in the local project emits pre‑existing warnings unrelated to this change.
    - A separate PR to dbt-semantic-interfaces is forthcoming.

wtremml18 and others added 3 commits January 16, 2026 11:00
Adds support for including `metric_time` in metric filter group_by clauses, ensuring filter metrics align with the parent query's time grain. Updates parsing, spec resolution, and query rendering to handle metric_time as a special case, and adds related tests and example metrics. Also includes minor refactoring and test fixture updates to support the new behavior.
@plypaul
Copy link
Contributor

plypaul commented Jan 28, 2026

I don't have much context on the customer request, but some thoughts that came to mind:

  • Filter time is ignored because there is no query time dimension. - Ignoring an argument seems a little odd since the rule for handling a metric in the filter was to join on all the group-by items specified in Metric(...).

  • If metric_time is in the filter, the filter is evaluated at the query grain (day/week/month/etc.). - This could be confusing for query authors as the behavior is different from how metric_time works in other places.

  • If a filter has a group-by metric and the same group-by metric but with metric_time, is there a column-name collision in the generated query?

@wtremml18
Copy link
Author

Hi Paul - thanks for the quick reply!

  1. “Filter time is ignored…”. Sorry, my phrasing was a little sloppy here...

When the parent query doesn’t group by metric_time__*, the query plan never introduces the time dimension. MetricFlow resolves the base and filter metrics at their default grains

So nothing is discarded per se; the resolver just never adds the metric_time column because the user didn’t request any time grain.

  1. “If metric_time is in the filter, the filter is evaluated at the query grain... this could be confusing because in other places metric_time behaves differently”

I see metric_time as basically a special entity, so I wanted to match the pattern of entity-based filtering in a metric-as-filter.

  • The PR only joins metric_time when the query itself contains a time dimension.
  • If the filter metric won't support the requested grain (e.g., filter metric is monthly-only but the query asks for daily), the query fails at resolution with a clear error.

While metric_time isn't explicitly treated like an entity in the documentation, it matches the mental model of “metric filters behave like ‘WHERE Metric(...) > …’ at the same grain as the SELECT.”

Also note that this is fully backwards compatible, so it only affects users who go out of their way to intentionally use a metric filter intra-period.

  1. “If a filter has a group-by metric and the same group-by metric but with metric_time, is there a column-name collision?”

I tested a few different combinations of edge-cases: Two metric filters, one with metric_time and one without, queried with and without --group-by metric_time__*

The generated SQL only projects metric_time__month once in the outer SELECT. Inside the joins, both the base metric and the filter metric use metric_time__month, but MetricFlow aliases everything. There’s no column-name collision because the final projection is the parent query’s metric_time__month.

There is an unexpected behavior where the result functionally prioritizes the group_by=['klaviyo_account_id','metric_time']) filter.

I can add a warning on-parse if that's a better failure mode - looks like a pretty basic addition!

@plypaul
Copy link
Contributor

plypaul commented Feb 5, 2026

Issues with the currently proposed solution

If metric_time is in the filter, the filter is evaluated at the query grain (day/week/month/etc.). - This could be confusing for query authors as the behavior is different from how metric_time works in other places.

To expand on the point above, our main concern is how this fits into the overall consistency of the query interface.

Metric('bookings', ['listing', 'metric_time']) is currently understood as a (sub)query. For a query like bookings by listing, metric_time that does not specify the grain for metric_time, the current behavior is that the grain for metric_time resolves to the smallest one available for that metric. The logic proposed in this PR does not follow the same behavior.

Another way to evaluate this is from a documentation perspective. Ideally, this behavior should be explainable in documentation without relying on conditional rules (“if… then…”). That constraint is important for keeping the mental model simple and predictable for users.

There’s no column-name collision because the final projection is the parent query’s metric_time__month.

Can you share the generated SQL for a filter case like the following? It would help clarify how the two metric subqueries are used.

{{ Metric('bookings', ['listing', 'metric_time']) }} > 2 AND {{ Metric('bookings', ['listing']) }} > 3

For the example metric in the PR:

---
metric:
  name: active_listings_with_metric_time
  description: Listings with at least 2 bookings in the same period
  type: simple
  type_params:
    measure: listings
  filter: "{{ Metric('bookings', ['listing', 'metric_time']) }} > 2"

How does metric_time get resolved when the user issues a query that includes multiple instances of metric_time at different grains? For example, assuming fiscal_quarter is a custom grain defined in the manifest:

active_listings_with_metric_time by metric_time__quarter, metric_time__fiscal_quarter

Thoughts on a path forward

Following up on an internal discussion, we see the value in the proposed feature, but we still have concerns about the interface.

One possibility is that this use case fits better into a more general solution. Several users have asked about defining metrics with query-time parameters (e.g. a cumulative metric where the window is specified at query time). The grain for metric_time is acting like a query-time parameter here. Unfortunately, that idea isn’t fully fleshed out yet.

A concrete approach we would support right now is requiring an explicit grain for metric_time in the filter and defining multiple metrics at different grains. For example:

---
metric:
  name: active_listings_daily
  description: Listings with at least 2 bookings in the same day
  type: simple
  type_params:
    measure: listings
  filter: "{{ Metric('bookings', ['listing', 'metric_time__day']) }} > 2"
---
metric:
  name: active_listings_monthly
  description: Listings with at least 2 bookings in the same month
  type: simple
  type_params:
    measure: listings
  filter: "{{ Metric('bookings', ['listing', 'metric_time__month']) }} > 2"

While this isn’t ideal due to repetition, it lets us make progress on your request without introducing additional semantics for metric_time. It should also be backwards-compatible if we later add support for metric_time without a grain, or introduce a more general query-time parameter mechanism.

If you’re planning to update the PR, can you also split the monolithic commit into smaller, logical commits to make review easier? For example: https://github.com/dbt-labs/metricflow/pull/1966/commits

@plypaul
Copy link
Contributor

plypaul commented Feb 12, 2026

@wtremml18 Checking to see if you have thoughts on the above?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants