Shopify Communities Program
The Communities program is a group-buy sales campaign. A school or PTO organiser runs a bulk purchase, participants order using a shared discount code, and pricing improves as the group hits volume tiers.
The campaign roster is RAW_DB.SHOPIFY.METAOBJECT_COMMUNITY, one row per Shopify community metaobject
entry. Its FIELD_VALUES column is a dictionary holding every field the Shopify admin shows —
discount_code, status_label, window_opens_at, window_closes_at, current_count,
target_devices, charged_tier, final_tier, org_type, organiser contact details, and so on.
Two unrelated things are named "community"
Never join these two tables. They share a name and nothing else.
| Table | What it is |
|---|---|
RAW_DB.SHOPIFY.METAOBJECT_COMMUNITY |
The group-buy sales campaign roster. Use this for anything about the Communities program. |
RAW_DB.TINCAN.COMMUNITY |
An unrelated in-app product feature — a roster of families so kids can call each other. Nothing to do with sales campaigns. |
A question about the Communities program — how many communities, which schools, how much they bought,
whether a campaign closed — is always about METAOBJECT_COMMUNITY. Answering it from
RAW_DB.TINCAN.COMMUNITY returns a far smaller number and a completely different concept, with nothing
in the output indicating the error.
The two tables have no shared key. Any join between them is wrong by construction, even if it runs.
Joining the roster to orders
The join key is the discount code, and it is not a column on either side — it sits inside semi-structured data in both tables, and the orders side must be flattened first. This join cannot be inferred from the schema; use this pattern.
with order_codes as (
select lower(dc.value:code::string) as code,
o.id as order_id
from raw_db.shopify.orders o,
lateral flatten(input => o.discount_codes) dc
where o.tags ilike '%community_order%'
)
select c.handle,
c.display_name,
c.field_values:discount_code::string as discount_code,
c.field_values:status_label::string as status_label,
count(distinct oc.order_id) as orders
from raw_db.shopify.metaobject_community c
left join order_codes oc
on lower(c.field_values:discount_code::string) = oc.code
group by 1, 2, 3, 4
order by orders desc, c.display_name;
Five things this pattern is doing deliberately:
The roster drives, with a LEFT JOIN. Every community appears, including those with no orders at all
— which is most of the point of having this table. An inner join, or driving from orders, silently drops
exactly the campaigns that order data alone can never show you. count(distinct oc.order_id) correctly
returns 0 for them; count(*) would wrongly return 1.
The flatten sits in a CTE. lateral flatten is correlated to orders, so it cannot be left-joined to
directly. Flattening to one row per order-and-code first makes the roster available as the driving table.
lateral flatten on orders.discount_codes. That column is an array of objects, so the code lives at
dc.value:code, not on the order row.
lower() on both sides. Shopify treats discount codes as case-insensitive at redemption, so the
casing recorded on an order is not guaranteed to match the casing stored on the metaobject.
The community_order tag filter. Without it the flatten also picks up staff-entered manual discounts
recorded by reason text — replacement, custom discount, failed delivery — plus referral and test
codes, none of which are community campaigns.
Group on handle, not display_name. Handles are unique per entry; display names are not — two distinct
entries can carry the same name, and grouping without the handle silently merges them into one row.
To narrow to communities that did sell, filter the result rather than changing the join:
having count(distinct oc.order_id) > 0.
Identifying community orders without the roster
orders.tags ilike '%community_order%' is the reliable flag for whether an order belongs to the
program. It is applied automatically. It does not tell you which community — that requires the join
above.
The roster contains campaigns that never sold
Many roster entries have no matching orders at all. This is expected, not a join failure: a campaign that opened and never sold anything leaves no trace in order data. Answering "how many communities do we have?" from orders alone undercounts, sometimes badly. Count from the roster.
Use field_values:status_label and the window_opens_at / window_closes_at fields for lifecycle
questions, which order data cannot answer at all.
Do not sum per-community figures across rows
discount_code is not unique in this table. A school district runs one campaign under a single code
but gets a separate roster entry for each participating school. So one code can map to several rows.
Critically, target_devices and current_count belong to the campaign, not to the school, and the same
value is copied onto every school's row. Summing them across rows counts a district's campaign once per
school and materially overstates the total — with nothing in the data signalling the error.
Aggregate per code first:
-- correct: one row per campaign
with per_campaign as (
select lower(field_values:discount_code::string) as discount_code,
max(try_to_number(field_values:target_devices::string)) as target_devices,
max(try_to_number(field_values:current_count::string)) as current_count
from raw_db.shopify.metaobject_community
group by 1
)
select count(*) as campaigns,
sum(target_devices) as target_devices,
sum(current_count) as current_count
from per_campaign;
The same duplication makes an orders-to-roster join many-to-many rather than one-to-one, which matters
in a way that is easy to miss. A per-community count(distinct o.id) is correct for that community —
but adding those per-community counts together exceeds the true number of orders, because an order
placed under a district-wide code is counted once for every participating school.
So do not derive a program-wide order total by summing a per-community breakdown. Count it directly
against orders instead:
select count(distinct id) as community_orders
from raw_db.shopify.orders
where tags ilike '%community_order%';
"How many communities do we have?" has two valid answers
Roster entries and distinct campaigns are different numbers, and both are defensible. State which one is being reported. Entries are the right unit for "how many schools are participating"; distinct discount codes are the right unit for "how many campaigns are running".
What this data cannot answer
Per-school attribution inside a district. The discount code is district-wide, so an order can only be attributed to the district, never to a specific school within it. This is not derivable at any grain — do not attempt to infer it.
Querying FIELD_VALUES
Values are strings as Shopify returned them; cast as needed (try_to_number, try_to_timestamp_tz).
Shopify returns unset fields as JSON null rather than omitting them, so every field key appears on
every row. Checking whether a key is present proves nothing about whether it has a value, and a bare
is not null on a VARIANT path is always true. Use is_null_value():
-- entries that have actually recorded a final tier
select count(*)
from raw_db.shopify.metaobject_community
where not is_null_value(field_values:final_tier);
hero_image holds a gid://shopify/MediaImage/... reference, not an image URL.
Freshness and load behaviour
This table is loaded by a scheduled Retool Workflow against the Shopify Admin GraphQL API, not by Airbyte — Shopify exposes metaobjects only through GraphQL, and no ELT connector supports them. Each run replaces the table with a full snapshot, so an entry deleted in Shopify disappears here too.
_LOADED_AT records when the load last succeeded and is uniform across all rows. Use it, not
UPDATED_AT, to judge whether the data is current: UPDATED_AT is Shopify's last-edited time and goes
stale whenever organisers simply stop editing the roster.