Product Catalogues
How to build product_catalogue_canonical and destination projection models for ad-platform catalogues (Meta Catalog, TikTok Catalog, Reddit Catalog) that power dynamic product ads and product-set targeting.
This guide explains how to build the dbt-nexus catalogue pipeline for ad-platform product catalogues (Meta Catalog, TikTok Catalog, Reddit Catalog, and others).
A catalogue is the door through which product and grouping metadata reaches
an ad platform. It is the counterpart to the
Marketing Conversions pipeline: conversions
carry the event (someone bought item X); the catalogue describes what item
X is and which group it rolls up to. Dynamic product ads (DPA / Dynamic
Showcase Ads), product-set targeting, and catalogue-based audiences all read the
catalogue, not the event payload.
The pattern is the same two-layer, composable shape used by marketing conversions:
- Define the catalogue once in a canonical model (
product_catalogue_canonical) - Project thin, platform-specific feed tables (
product_catalogue_meta, …) - Deliver with the transport each platform supports (API push or hosted feed)
Why a warehouse-derived catalogue
Ad platforms treat the catalogue as a separate optimisation surface from the pixel/CAPI event. Meta, for example, is three machines: matching (identity), optimisation (conversion signal + catalogue attributes), and audiences. There is no door for stuffing high-cardinality product metadata onto the event and hoping the optimiser reads it — that metadata belongs in the catalogue, and targeting rides on product sets (a saved filter over catalogue fields), never on a raw pixel parameter. TikTok and Reddit follow the same model.
This makes the catalogue a data-modelling problem, exactly like conversions:
- Single source of truth — item identity, grouping, price, and availability come from the same warehouse models operations and finance already trust.
- Coverage contract — the catalogue must be a superset of every item id that can appear on an event (see below). Deriving it from the warehouse is the only way to guarantee that.
- Version control — every change to the feed shape or grouping logic is a git commit with review and tests, not a click in an ads-manager UI.
- Destination-agnostic — add a platform with a thin Layer 2 projection; grouping and metadata rules stay in Layer 1 once.
The item-id ↔ conversion join
The single most important contract: the catalogue's item id must exactly
match the product id sent on the corresponding marketing event. Platforms call
this field different names — Meta content_ids, TikTok id ("must match your
pixel events"), Reddit Product ID — but it is the same value, and it is the
join key that makes DPA and product-set logic work.
In practice this means the catalogue and the Marketing Conversions pipeline agree on the same id. The conversion event emits it as the product/content id; the catalogue lists it as a row. If an event fires an id the catalogue does not yet know, that item goes silently untargetable — no error, just no matching.
Architecture
flowchart LR
src[Source Systems] --> nexusEnt[nexus_entities / nexus_relationships]
nexusEnt --> layer1[product_catalogue_canonical]
layer1 --> meta[product_catalogue_meta]
layer1 --> tiktok[product_catalogue_tiktok]
layer1 --> reddit[product_catalogue_reddit]
meta --> push[API push / reverse ETL]
tiktok --> push
reddit --> feed[Hosted feed file]
The pipeline has two layers, mirroring the conversions pipeline:
| Layer | Model | Materialization | Purpose |
|---|---|---|---|
| 1 | product_catalogue_canonical |
table |
One row per catalogue item: identity, grouping, descriptive fields, custom labels |
| 2 | product_catalogue_meta, product_catalogue_tiktok, product_catalogue_reddit |
view |
Thin platform projections with feed-native column names and per-platform rules |
All grouping and metadata logic belongs in Layer 1. Layer 2 models are filtered views that rename columns to feed-native names and apply platform-specific requirements (required-field minimums, item-type vocab). They do not re-implement business logic.
Directory structure
models/
outputs/
product-catalogues/
product_catalogue_canonical.sql -- Layer 1
product_catalogue_meta.sql -- Layer 2: Meta Catalog
product_catalogue_tiktok.sql -- Layer 2: TikTok Catalog
product_catalogue_reddit.sql -- Layer 2: Reddit Catalog
The shared product_catalogue_ prefix groups the models together in the
warehouse table listing, the same way marketing_conversions_ does.
Layer 1: product_catalogue_canonical
This is the canonical catalogue model. One row per catalogue item. It owns:
- Item identity (
item_id) — the value that must match the conversion product/content id. - Grouping (
group_id) — the parent the item rolls up to for targeting (e.g. a title, a campaign, a collection). Emitted to every platform ascustom_label_0. - Descriptive fields — title, description, price, currency, availability, condition, image, link, brand.
- Item type — the vertical/kind of the row (used to drive eligibility and the descriptive defaults).
- Custom labels —
custom_label_0 = group_id, plus optionalcustom_label_1..4for genre, season, margin tier, etc.
The grouping item is itself a catalogue row
The most important modelling decision: the group is also a catalogue item.
Many engageable things have no sellable SKU — a trailer view, a landing-page
view, a title with no per-ticket SKU. For those, the event's product id is the
group id itself, so the group must exist as a catalogue row
(item_id = group_id) or those events go unmatched.
This gives a single uniform id rule, identical to the conversion side:
item_id = asset_id (when the item has its own SKU)
?? group_id (otherwise — the group row stands in)
A per-group product set then unites the group row and all of its child item
rows. (A24 example: the movie is a catalogue item with
item_id = project_id; merch, tickets, VOD, and trailers are child rows with
custom_label_0 = project_id. The "Robin Hood" product set is everything where
custom_label_0 = mov_123.)
Coverage and freshness contract
The catalogue must be a superset of every
item_idthat can appear on an event, refreshed ahead of go-live.
This is a hard contract with no fallback. If an event fires an item_id the
catalogue does not know yet — a new SKU, a new title on sale before the feed runs
— it is silently untargetable. New-item onboarding therefore means "register
in the catalogue before it is purchasable / engageable." Build Layer 1 from the
union of every sellable/engageable asset plus every group, not from
historical orders.
Source data
Unlike conversions (which read nexus_events), the catalogue is built from
entities and their relationships — the products/assets and their parent
groups — not the event log:
| Model | Purpose |
|---|---|
nexus_entities |
Product / asset / title rows and their descriptive traits |
nexus_relationships |
Asset → group membership (never join on trait columns) |
| source product tables | Native catalogue rows (e.g. Shopify products) and SKU details |
Never join on trait columns of
nexus_entities(e.g. a*_product_idtrait) to resolve grouping. Identity resolution collapses traits viamax(), so a trait can point at the wrong source row. Usenexus_relationshipsto connect an item to its group. See the schema reference for why.
Model shape
{{ config(
materialized='table',
tags=['catalogue', 'outputs']
) }}
WITH items AS (
-- 1. Every sellable / engageable asset, one row each.
SELECT
a.asset_id as item_id,
a.group_id,
a.item_type, -- 'merch' | 'ticket' | 'vod' | 'trailer' | ...
a.title,
a.description,
a.price,
a.currency,
a.availability, -- 'in stock' | 'out of stock' | 'preorder'
a.condition, -- 'new' | 'used' | 'refurbished'
a.image_link,
a.link,
a.brand
FROM {{ ref('your_assets') }} a
),
groups AS (
-- 2. The group itself as a catalogue item, so SKU-less events resolve.
SELECT
g.group_id as item_id,
g.group_id,
'group' as item_type,
g.group_title as title,
g.group_description as description,
0 as price, -- not directly sold
'USD' as currency,
'in stock' as availability,
'new' as condition,
g.group_image_link as image_link,
g.group_link as link,
g.brand
FROM {{ ref('your_groups') }} g
),
unioned AS (
SELECT * FROM items
UNION ALL
SELECT * FROM groups
),
catalogue_mapped AS (
-- 3. Custom labels + descriptive defaults. One row per item_id.
SELECT
item_id,
group_id,
item_type,
title,
description,
price,
currency,
availability,
condition,
image_link,
link,
brand,
-- Custom labels (identical names across platforms)
group_id as custom_label_0, -- the rollup
item_type as custom_label_1, -- e.g. vertical
CAST(NULL AS VARCHAR) as custom_label_2,
CAST(NULL AS VARCHAR) as custom_label_3,
CAST(NULL AS VARCHAR) as custom_label_4,
ROW_NUMBER() OVER (
PARTITION BY item_id ORDER BY price DESC
) as rn
FROM unioned
)
SELECT * FROM catalogue_mapped WHERE rn = 1
Design decisions
item_idis the cross-platform / cross-pipeline key. It is what every platform's feedidand every conversion event's product id resolve to. Keep it stable; never regenerate it.custom_label_0 = group_idis the targeting hook. Product sets are saved filters over custom labels. Putting the group oncustom_label_0everywhere means a per-group product set is one filter on one field — portable across Meta, TikTok, and Reddit.- Descriptive fields are real columns, unhashed and queryable. No PII is involved (unlike conversions), so nothing is hashed.
- One row per
item_id. Dedupe in Layer 1 (theROW_NUMBERguard) so every Layer 2 feed inherits a clean superset.
Materialization
table for Layer 1 (read by every Layer 2 feed). view for Layer 2 (thin
projections that should always reflect the latest Layer 1).
Layer 2: Platform projections
Each platform gets a thin model that reads product_catalogue_canonical and
renames to that platform's feed field names. The three platforms are close
cousins of the Meta feed spec, so the projections are mostly renames plus a few
platform rules. See the per-platform guides:
- Meta Catalog —
content_idsjoin,custom_label_0product sets, Shopify-native merch dedup, Catalog Batch API - TikTok Catalog — required-field minimums,
custom_label_0..4, Catalog Batch API or scheduled feed URL, e-commerce-vertical-only wrinkle - Reddit Catalog — required fields, product groups, hosted feed file transport (no robust catalog API), DPA newer (GA May 2025)
Transport differs by platform
This is the main mechanical difference from the conversions pipeline, where every destination is an API push:
| Platform | Transport | Layer 2 output is… |
|---|---|---|
| Meta | Catalog Batch API (or hosted feed) | a table reverse ETL pushes to the API |
| TikTok | Catalog Batch API or scheduled feed URL (hourly–daily) | a table reverse ETL pushes, or a published feed |
| Hosted feed file only (CSV / Sheets / RSS 2.0 XML / TSV) over HTTPS / SFTP | a feed file published to a URL Reddit fetches |
So Meta and TikTok can be driven by the same reverse-ETL-to-API pattern as conversions. Reddit instead requires publishing the Layer 2 view as a hosted feed file at an HTTPS/SFTP URL on a schedule. Plan for one feed-publishing job in addition to the API push adapters.
Adding a platform
- Add any new custom-label or item-type mapping to Layer 1.
- Create a Layer 2 model selecting from
product_catalogue_canonicalwith feed-native names and the platform's required-field rules. - Wire transport: reverse ETL → catalog API, or a feed-publishing job.
- Add schema tests (
item_idnot_null + unique; required fields not_null).
Data quality
models:
- name: product_catalogue_canonical
columns:
- name: item_id
description: >
Catalogue item id. Must match the product/content id sent on the
corresponding marketing conversion event.
tests:
- not_null
- unique
- name: group_id
tests:
- not_null
- name: custom_label_0
description: Rollup group; the product-set targeting key on every platform.
tests:
- not_null
Beyond schema tests, add a coverage check: every product/content id that
appears in marketing_conversions_canonical should exist as an item_id in
product_catalogue_canonical. A failing row is an item that is silently
untargetable.
Reference
- Marketing Conversions — the event-side
counterpart; shares the
item_id/ product-id contract - Meta Product Catalog
- TikTok Catalog
- Reddit Catalog Requirements