Weld logo

Sales by Product

Recreates Shopify's "Total sales by product": units, orders and revenue for every product, variant and SKU per day, so you can rank top sellers and break the numbers down by product type or vendor.

Source
Shopify
Level
Intermediate
Reads
staging.shopify.order, staging.shopify.order_line, staging.shopify.product
1-- shopify_sales_by_product - recreates Shopify's "Total sales by product"
2-- Grain: product x variant (SKU) x day, on the ORDER date. Currency: shop money.
3-- Depends on: staging.shopify.order, .order_line, .product
4--
5-- SCOPE: net of discounts, NOT net of returns. Does not reconcile to
6-- shopify_sales_over_time - returns stay on the original order date. Right for
7-- ranking top sellers, wrong for SKU revenue that has to tie out.
8with
9    orders as (
10        select
11            shopify_store
12          , order_id
13          , date(processed_at) as date
14        from
15            {{staging.shopify.order}}
16    )
17select
18    o.date
19  , l.shopify_store
20  , l.product_id
21  , -- Canonical product attributes beat the order_line snapshot, which was taken
22    -- at purchase time and goes stale if a product is renamed.
23    coalesce(p.product_title, l.product_title) as product_title
24  , p.product_type
25  , coalesce(p.vendor, l.vendor) as vendor
26  , l.variant_id
27  , l.variant_title
28  , l.sku
29  , sum(l.quantity) as units
30  , count(distinct o.order_id) as orders
31  , round(sum(l.gross_sales), 2) as gross_sales
32  , round(sum(l.discounts), 2) as discounts
33  , round(sum(l.gross_sales - l.discounts), 2) as net_sales
34from
35    {{staging.shopify.order_line}} l
36    -- shopify_store in every join key: order IDs are only unique within a store.
37    join orders o using (shopify_store, order_id)
38    left join {{staging.shopify.product}} p using (shopify_store, product_id)
39    -- Gift cards are deferred revenue, not product revenue. staging.shopify.order_line
40    -- keeps them, so exclude them here.
41where
42    not l.is_gift_card
43    -- Ordinals, not names: product_title and vendor exist on both joined tables, so
44    -- a bare column name here would be ambiguous.
45group by
46    1
47  , 2
48  , 3
49  , 4
50  , 5
51  , 6
52  , 7
53  , 8
54  , 9
55order by
56    o.date
57  , net_sales desc

Required models (4)

This model reads from staging models rather than raw tables. Deploy these first — Weld resolves the order for you.

staging.shopify.orderOrders with test orders removed, types cast, currency precedence resolved.
-- staging.shopify.order
-- Thin wrapper over raw `order`. Casts, renames, drops test orders. No other logic.
--
-- Single store. To add another, UNION ALL a second block below pointing at that
-- store's connector with a different shopify_store label. Keep it in staging so
-- the core models never have to know how many stores there are.
select
    'store_1' as shopify_store
  , cast(id as int64) as order_id
  , cast(name as string) as order_name
  , cast(customer_id as int64) as customer_id
  , cast(location_id as int64) as location_id
  , lower(cast(source_name as string)) as source_name
  , cast(processed_at as timestamp) as processed_at
  , cast(created_at as timestamp) as created_at
  , cast(cancelled_at as timestamp) as cancelled_at
  , cast(closed_at as timestamp) as closed_at
  , -- COALESCE to '' so downstream NOT IN (...) filters keep NULL-status orders
    -- rather than silently dropping them.
    lower(coalesce(cast(financial_status as string), '')) as financial_status
  , lower(cast(fulfillment_status as string)) as fulfillment_status
  , -- The order's own currency, preferred over the shop's CURRENT currency.
    -- Stores that changed base currency, or imported history from another
    -- platform, hold orders denominated in something else; defaulting to today's
    -- shop currency would mislabel them by the full FX factor.
    upper(
        nullif(
            trim(
                cast(
                    current_total_price_set_shop_money_currency_code as string
                )
            )
          , ''
        )
    ) as order_shop_currency
  , upper(nullif(trim(cast(currency as string)), '')) as currency
  , upper(
        nullif(
            trim(
                cast(
                    current_total_price_set_presentment_money_currency_code as string
                )
            )
          , ''
        )
    ) as order_presentment_currency
  , upper(
        nullif(trim(cast(presentment_currency as string)), '')
    ) as presentment_currency
  , upper(
        nullif(
            trim(cast(shipping_address_country_code as string))
          , ''
        )
    ) as shipping_country_code
  , upper(
        nullif(
            trim(cast(billing_address_country_code as string))
          , ''
        )
    ) as billing_country_code
from
    {{raw.shopify.order}}
where
    coalesce(test, false) = false
staging.shopify.order_lineProduct identity and line money, plus gift-card and requires-shipping flags.
-- staging.shopify.order_line
-- Product identity and line money. Gift cards are NOT filtered here - staging
-- stays neutral and consumers decide. `is_gift_card` and `requires_shipping` are
-- exposed because the sales report needs them to classify orders.
--
-- Single store. To add another, UNION ALL a second block below pointing at that
-- store's connector with a different shopify_store label. Keep it in staging so
-- the core models never have to know how many stores there are.
select
    'store_1' as shopify_store
  , cast(id as int64) as line_id
  , cast(order_id as int64) as order_id
  , cast(product_id as int64) as product_id
  , cast(variant_id as int64) as variant_id
  , cast(sku as string) as sku
  , nullif(trim(cast(title as string)), '') as product_title
  , cast(variant_title as string) as variant_title
  , cast(vendor as string) as vendor
  , cast(quantity as int64) as quantity
  , coalesce(cast(gift_card as bool), false) as is_gift_card
  , coalesce(cast(requires_shipping as bool), true) as requires_shipping
  , coalesce(
        cast(price_set_shop_money_amount as numeric)
      , cast(price as numeric)
    ) as unit_price
  , coalesce(
        cast(price_set_shop_money_amount as numeric)
      , cast(price as numeric)
    ) * cast(quantity as int64) as gross_sales
  , coalesce(
        cast(total_discount_set_shop_money_amount as numeric)
      , cast(total_discount as numeric)
      , 0
    ) as discounts
from
    {{raw.shopify.order_line}}
staging.shopify.productCanonical product title, type and vendor.
-- staging.shopify.product
-- Canonical product attributes. order_line carries a snapshot taken at purchase
-- time; prefer these when a product was later renamed or recategorised.
--
-- Single store. To add another, UNION ALL a second block below pointing at that
-- store's connector with a different shopify_store label. Keep it in staging so
-- the core models never have to know how many stores there are.
select
    'store_1' as shopify_store
  , cast(id as int64) as product_id
  , cast(title as string) as product_title
  , cast(product_type as string) as product_type
  , cast(vendor as string) as vendor
  , lower(cast(status as string)) as status
from
    {{raw.shopify.product}}
analytics.shopify.sales_by_productThin BI-facing contract over the core model - bind dashboards here, not to core.
-- analytics.shopify.sales_by_product
-- BI-facing contract over the core model. See analytics/sales_over_time.sql for
-- why this layer exists even when it is a passthrough.
select
    *
from
    {{core.shopify.sales_by_product}}

Example output

+ ------------+---------------+--------------+----------+---------------+----------+-------+--------+-------------+-----------+-----------+
| date | product_title | product_type | vendor | variant_title | sku | units | orders | gross_sales | discounts | net_sales | + ------------+---------------+--------------+----------+---------------+----------+-------+--------+-------------+-----------+-----------+
| 2026 -03 -05 | Product 1 | Category A | Vendor A | Variant A | SKU -0001 | 38 | 36 | 6839.62 | -512.00 | 6327.62 | | 2026 -03 -05 | Product 2 | Category B | Vendor A | Variant A | SKU -0002 | 52 | 49 | 4159.48 | -374.35 | 3785.13 | | 2026 -03 -05 | Product 3 | Category C | Vendor B | Variant A | SKU -0003 | 14 | 14 | 3919.86 | -280.00 | 3639.86 | | 2026 -03 -05 | Product 4 | Category D | Vendor B | Variant A | SKU -0004 | 23 | 22 | 2298.77 | -149.90 | 2148.87 | | 2026 -03 -05 | Product 1 | Category A | Vendor A | Variant B | SKU -0005 | 11 | 11 | 1979.89 | -98.00 | 1881.89 | + ------------+---------------+--------------+----------+---------------+----------+-------+--------+-------------+-----------+-----------+

Rolls order lines up to product, variant and SKU per day, joined to the product table for canonical titles, product type and vendor. Use it to rank top sellers, chart a single product over time, or compare categories and suppliers. One scope note worth understanding: this model reads order_line on the order date, so it reports gross and net of discounts but not net of returns. That is the right trade-off for ranking - relative ranking barely moves whether or not returns are netted out, and order_line is where product identity actually lives - but it means the numbers will not reconcile day-by-day with the sales report, because returns stay attached to the original order date rather than reducing the SKU that came back. For product-level revenue that has to tie out, you need the agreement event grain at line level.

Browse every Shopify SQL template, or all templates.