In the Semantic Layer you never write a join. You give two semantic models an entity with the same name, and MetricFlow joins them whenever a query needs a dimension from the other model. The rules that decide whether it may do so come from the entity types.

Copy and paste

models:
  - name: orders
    semantic_model:
      enabled: true
    agg_time_dimension: order_date
    columns:
      - name: order_id
        entity: {type: primary, name: order}
      - name: customer_id
        entity: {type: foreign, name: customer}     # orders -> customers
      # ... dimensions and metrics

  - name: customers
    semantic_model:
      enabled: true
    columns:
      - name: customer_id
        entity: {type: primary, name: customer}     # same name: the join key
      - name: country_code
        entity: {type: foreign, name: country_code} # customers -> countries
      - name: country
        dimension: {type: categorical}

  - name: countries
    semantic_model:
      enabled: true
    columns:
      - name: country_code
        entity: {type: primary, name: country_code}
      - name: region
        dimension: {type: categorical}

One hop and two hops:

mf query --metrics revenue --group-by customer__country
mf query --metrics revenue --group-by customer__country_code__region
customer__country_code__region      revenue
--------------------------------  ---------
Southern Europe                        1240
Western Europe                          990

How the name tells the path

A dimension from another model is addressed through the entities MetricFlow walks to reach it, joined by double underscores. From orders, customer__country means “follow customer, take country”, and customer__country_code__region follows customer and then country_code. MetricFlow allows up to two hops.

The path starts from where the metric lives. For signups, defined on customers, the region is one hop away and is called country_code__region. Asking for customer__country_code__region fails, and the error suggests the right name:

    The given input does not match any of the available group-by-items for
    SimpleMetric('signups'). ...
    Suggestions:
      [
        'customer__country_code',
        'country_code__region',
        ...

The generated SQL

--explain (or --compile with dbt sl) shows the join. The two-hop query builds the customer-to-region lookup in a subquery and left-joins it to orders:

SELECT
  subq_10.country_code__region AS customer__country_code__region
  , SUM(orders_src_10000.amount) AS revenue
FROM "shop"."main"."orders" orders_src_10000
LEFT OUTER JOIN (
  SELECT
    customers_src_10000.customer_id AS customer
    , countries_src_10000.region AS country_code__region
  FROM "shop"."main"."customers" customers_src_10000
  LEFT OUTER JOIN
    "shop"."main"."countries" countries_src_10000
  ON
    customers_src_10000.country_code = countries_src_10000.country_code
) subq_10
ON
  orders_src_10000.customer_id = subq_10.customer
GROUP BY
  subq_10.country_code__region

Left joins keep every order, even one whose customer is missing from customers; those orders show up with an empty region instead of disappearing from the total.

Joins that are not allowed

MetricFlow only joins towards a side with at most one row per key: foreign or primary to primary or unique. A metric on customers grouped by an order dimension would need the opposite, and is refused:

mf query --metrics signups --group-by order__status
ERROR: Got error(s) during query resolution.
    The given input does not match any of the available group-by-items for
    SimpleMetric('signups'). Common issues are:
      * Incorrect names.
      * No valid join paths exist from the measure to the group-by-item.
        (fan-out join support is pending).

That protects the number: each customer has several orders, and the join would count the same signup several times. If you need “signups by the status of their first order”, build that attribute on the customers model (for example first_order_status) in dbt, and expose it as a dimension there.

Metrics from two models in one query

mf query --metrics revenue,signups --group-by customer__country
customer__country      revenue    signups
-------------------  ---------  ---------
DE                         990          2
FR                                      1
IT                        1240          3

Each metric is computed on its own model, then the results are combined with a full outer join on the group-by columns. France has a signup but no revenue, and still appears.

Tested with dbt Core 1.12 and MetricFlow 0.213 on DuckDB.

References