Why rebuild Shopify's sales report in dbt?

Summary: Shopify's sales reports are the numbers your finance team trusts and your board deck repeats, but they live behind an admin UI with a date picker and a CSV button. This guide is the production dbt blueprint for reproducing them in your warehouse: the sales equation component by component, why order_line is the wrong source table, the raw → staging → core → analytics lineage, annotated SQL for every metric, the parity gotchas that cost a week, and multi-store plus multi-currency handling. Every example is dbt on BigQuery; the logic ports to Snowflake, Databricks, Redshift or Postgres with minor dialect changes.

You need your own model once you have to do any of these:

  • Join sales to ad spend, COGS or shipping cost to get real profit per SKU
  • Report across multiple Shopify stores as one business
  • Report in a single reporting currency across stores that sell in many
  • Keep history at daily grain beyond Shopify's retention
  • Put sales next to marketing, support and supply-chain data in one BI layer

And this is where nearly everyone gets it wrong the first time: they SUM(price * quantity) off order_line, compare it to the admin, and find they're off by anywhere from 2% to 20%.

Just want a working query? If you're not on dbt and you have a single store, the standalone SQL version of this report is one query you can paste in and run today. This guide is what you graduate to when you have several storefronts, several currencies, and a finance team asking why the numbers moved.

Part 1 — The Shopify sales equation

Before writing a line of SQL, know exactly what you're reproducing. Shopify's sales reports are built on one equation, applied at whatever grain you slice by (day, product, channel, location, staff):

  Gross sales        product price x quantity, before anything else
- Discounts          order- and line-level discounts (shown negative)
- Returns            value of returned items (shown negative)
-----------------
= Net sales
+ Shipping charges
+ Duties
+ Additional fees
+ Taxes
-----------------
= Total sales

The definitions that trip people up

ComponentWhat it actually isCommon mistake
Gross salesLine price × quantity, excluding tax, shipping, discounts and returnsIncluding tax, or netting discounts out of it
DiscountsAll discounts applied before tax - code, automatic and manual. Stored negativeOnly counting discount codes, missing automatic and manual discounts
ReturnsValue of items returned or refunded, attributed to the refund date, stored negativeAttributing returns back to the original order date
Net salesGross sales – Discounts – Returns (with negative signs, this is arithmetic addition)Subtracting positive numbers twice and double-counting
Shipping chargesWhat the customer paid for shipping, excluding taxConfusing it with what shipping cost you - a different metric entirely
DutiesImport duties charged to the customerRolling them into shipping
Additional feesLocal or regulatory fees (bottle deposits, e-waste levies)Ignoring; usually 0 for most stores
TaxesVAT or sales tax collectedIncluding in gross sales
Total salesNet sales + shipping + duties + fees + taxesComparing against payouts, which are net of processing fees

Sign conventions: pick one and enforce it

The single biggest source of "why is my number wrong" is inconsistent signs. Adopt Shopify's own convention and never deviate:

  • Discounts are negative. A 100 discount is stored as -100.
  • Returns are negative. A 500 return is stored as -500.
  • Therefore net_sales = gross_sales + discounts + returns arithmetically, which is why in SQL you'll see gross_sales derived as net_sales - discounts - returns - subtracting a negative adds it back.

Once the convention is fixed, every downstream formula is a plain sum and every BI tool aggregates correctly without special-casing.

Total sales vs "total Shopify sales"

Worth building both:

1-- Shopify's Total sales (matches the UI)
2net_sales + shipping_charges + duties + return_fees + additional_fees + taxes as total_shopify_sales
3  , -- Revenue-recognition friendly: excludes tax and duties (pass-through money)
4net_sales + shipping_charges + return_fees + additional_fees as total_sales

Finance almost always wants the second. The UI shows the first. Ship both columns and let each consumer pick - arguing about which is "correct" wastes a quarter.

Part 2 — The source tables that actually matter

Why order_line is the wrong table

The intuitive model is: an order has lines, lines have price and quantity, sum them up. It fails for a specific structural reason.

order_line is a current-state table. It tells you what the order looks like right now. Shopify's sales reports are event-based - they attribute value to the day the value changed. An order placed on the 1st, edited on the 3rd and partially refunded on the 20th produces value on three separate days. A current-state table can only tell you the end state.

Sum order_line and you get returns silently netted back to the original order date, invisible order edits, no way to reproduce a sales-over-time chart that matches the admin, and restated history - yesterday's report changes when someone refunds an old order.

The tables you actually need

Shopify exposes an event log through order agreements. Every change to an order's financial state creates an agreement, and each agreement carries the sale lines that changed.

TableRole in the sales reportRequired?
orderOrder header: status, currency, location, channel, addresses, test flagYes
order_agreementThe event log: one row per financial event, with happened_at + reasonYes
order_agreement_saleLine-level financial deltas per event - the heart of the reportYes
order_lineProduct identity (SKU, title, variant), gift-card flag, requires_shippingYes
order_refund / order_line_refundRefund dates, restock type, per-line refund amountsYes
shopStore's base currency (fallback only)Yes
locationPhysical and virtual location names, used to resolve location_nameRecommended
product / product_variantCanonical SKU, product statusRecommended
order_shipping_lineShipping method chosen - used to classify delivery typeOptional
transaction / transaction_feesPayment processor fees - needed for profit, not for salesOptional
FX rates (external)Daily exchange rates for multi-currency reportingIf multi-currency

The two columns that decide everything

Inside order_agreement_sale, two enum columns drive the entire report.

line_type — what kind of money this is:

line_typeMeaningFeeds
PRODUCTA product lineGross sales, discounts, net sales, quantity
SHIPPINGShipping charged to the customerShipping charges
DUTYImport dutiesDuties
FEEFees, typically return or restocking feesReturn fees
GIFT_CARDGift card purchaseGift card sales (excluded from product sales)
ADJUSTMENTReturn adjustments not tied to a product lineReturns
TIPTipsUsually excluded from sales

action_type — which direction the money moved:

action_typeMeaningRow type
ORDEROriginal saleSALE
UPDATEOrder edit adding or changing valueSALE
RETURNRefund or returnRETURN

Combine them and you get a matrix every metric selects a cell from:

PRODUCTSHIPPINGDUTYFEEGIFT_CARDADJUSTMENT
SALE (ORDER, UPDATE)gross sales, discounts, net sales, qtyshipping chargesdutiesfeesgift card sales
RETURN (RETURN)returns, reversals, returned qtyshipping reversalsreturn feesreturns (no SKU)

None of this exists in isolation - order_agreement_sale sits inside Shopify's full relational model alongside order, order_line, customer, product and everything else Weld syncs. Here's the live schema, the same explorer you'd get from Shopify's connector page:

Open full-page schema explorer

Click through to the full-page schema explorer for the interactive version - pan, zoom, and search across every table and relationship.

Part 3 — The model architecture

Four layers, each with one job.

dbt architecture: three raw Shopify store schemas and an FX feed flow into staging models, then into two core sales models, then into two BI-facing analytics models

Each Shopify storefront lands in its own raw schema. Staging unions them behind a shopify_store key, so the two core models - and everything downstream - never know how many stores there are.

Raw — untouched ELT output. One schema per store. Never queried except by staging.

Staging — one model per source table. Union all stores, add a shopify_store key, cast types, nothing else. No business logic, no filters, no joins. This is what makes adding a fourth storefront a ten-minute job.

Core — where the reports live. core_shopify__sales_over_time is the Sales report at order × day × row-type grain. core_shopify__product_sales_over_time is the Sales-by-product report at order × line × day grain.

Analytics — thin and stable. Usually a SELECT * plus a join or two. The point is that dashboards bind to a contract you control, so you can refactor core freely.

1-- analytics__shopify_sales_over_time.sql
2select
3    *
4from
5    {{ ref('core_shopify__sales_over_time') }}

Part 4 — Staging: unioning multiple stores

If you run one Shopify store, skip to Part 5. If you run regional storefronts - and most scaling DTC brands do - solve this first, because it affects every model above it.

Whatever you do, do it here. Staging is the only layer that should know how many stores exist; core just carries a shopify_store key through every join.

The simple version: UNION ALL

Each store lands in its own schema, so a staging model becomes one block per store, each labelling its rows:

1-- stg_shopify__order_agreement_sale.sql
2{{ config(materialized='table') }}
3select
4    'store_1' as shopify_store
5  , /* ...columns... */
6from
7    {{ source('shopify_store_1', 'order_agreement_sale') }}
8union all
9select
10    'store_2' as shopify_store
11  , /* ...same columns... */
12from
13    {{ source('shopify_store_2', 'order_agreement_sale') }}

That is what the ready-made templates do. It is plain SQL, there is nothing to configure, and adding a storefront touches staging only.

When UNION ALL isn't enough

It breaks the moment the stores' columns diverge - and they do. A store connected last month has fields one connected two years ago doesn't, and UNION ALL fails on the column count rather than degrading gracefully.

If you're maintaining more than two or three storefronts, that stops being hypothetical and the fix is a macro that introspects each source's columns, takes the union of all column names, and fills missing ones with NULL:

1{% macro shopify_union(table_name, store_sources, store_column='shopify_store') %}
2  {# store_sources = [('raw_schema_a', 'store_a'), ('raw_schema_b', 'store_b'), ...] #}
3
4  {% set refs = [] %}
5  {% for source_name, _ in store_sources %}
6    {% do refs.append(source(source_name, table_name)) %}
7  {% endfor %}
8
9  {% if not execute %}
10    SELECT * FROM {{ refs[0] }} WHERE 1 = 0
11  {% else %}
12
13    {# Collect every column name across every store #}
14    {% set relation_columns = {} %}
15    {% for source_name, _ in store_sources %}
16      {% do relation_columns.update({
17           source_name: adapter.get_columns_in_relation(refs[loop.index0])
18         }) %}
19    {% endfor %}
20
21    {% set all_columns = [] %}
22    {% set seen = [] %}
23    {% for source_name, _ in store_sources %}
24      {% for column in relation_columns[source_name] %}
25        {% if column.name | lower not in seen %}
26          {% do all_columns.append(column.name) %}
27          {% do seen.append(column.name | lower) %}
28        {% endif %}
29      {% endfor %}
30    {% endfor %}
31
32    {# Emit one SELECT per store, NULL-filling anything it lacks #}
33    {% for source_name, store_label in store_sources %}
34      {% set present = relation_columns[source_name]
35                       | map(attribute='name') | map('lower') | list %}
36      SELECT
37        {% for col in all_columns %}
38          {% if col | lower in present %}
39            {{ adapter.quote(col) }},
40          {% else %}
41            NULL AS {{ adapter.quote(col) }},
42          {% endif %}
43        {% endfor %}
44        '{{ store_label }}' AS {{ store_column }}
45      FROM {{ refs[loop.index0] }}
46      {% if not loop.last %}UNION ALL{% endif %}
47    {% endfor %}
48
49  {% endif %}
50{% endmacro %}
51

Each staging model then becomes three lines:

1-- stg_shopify__order_agreement_sale.sql
2{{ config(materialized='table') }}
3
4{% set store_sources = [
5    ('shopify_raw_store_1', 'store_1'),
6    ('shopify_raw_store_2', 'store_2'),
7    ('shopify_raw_store_3', 'store_3')
8] %}
9
10{{ shopify_union('order_agreement_sale', store_sources) }}
11

Either way, two rules will save you pain:

  1. shopify_store joins everywhere. Order IDs are only unique within a store. Every join, window partition and GROUP BY in every downstream model must include shopify_store. Forget it once and rows silently fan out across storefronts - every number inflates and nothing errors. Two related traps: your shop CTE must group by store rather than collapsing with ANY_VALUE, because currency and timezone vary between storefronts; and location IDs are per store, so those joins need the key too.
  2. Materialize the big ones as tables. order_agreement_sale and product_variant get hit repeatedly by the core models; leaving them as views makes the warehouse re-scan raw on every reference.

Part 5 — Building the sales-over-time model

The model, CTE by CTE:

orders      filter test + pending/voided/expired; resolve currency, country, location
agreements  exclude reason = 'voided'; carry happened_at + reason
sales       agreement sale lines; shop money + presentment money
    |
joined      agreements . sales . orders
            convert happened_at -> local date; flag checkout-flow edits
    |
typed       classify SALE vs RETURN
    |
agg         GROUP BY day, store, order, location, channel, row type
            -> every sales component
    |
final       components + parity variants + reporting currencies
            (joined with: dimensions, nearest-day FX)

Step 1 — Filter orders correctly

Your first CTE decides which orders exist at all. Get this wrong and nothing downstream can save you.

1with
2    shop as (
3        select
4            cast(shopify_shop as string) as shopify_shop
5          , any_value(upper(nullif(trim(cast(currency as string)), ''))) as shop_currency
6        from
7            {{ ref('stg_shopify__shop') }}
8        group by
9            1
10    )
11  , orders as (
12        select
13            cast(o.shopify_store as string) as shopify_store
14          , cast(o.id as int64) as order_id
15          , cast(o.name as string) as order_name
16          , cast(o.location_id as int64) as location_id
17          , lower(cast(o.source_name as string)) as source_name
18          , -- Currency resolution: the ORDER's own recorded currency wins over the
19            -- shop's CURRENT currency. Stores that changed base currency, or imported
20            -- historical orders from another platform, have orders genuinely
21            -- denominated in a different currency than the shop is today. Falling back
22            -- to the shop's current currency silently mislabels those amounts and can
23            -- inflate converted figures by the entire FX factor.
24            upper(
25                nullif(
26                    trim(
27                        coalesce(
28                            cast(
29                                o.current_total_price_set_shop_money_currency_code as string
30                            )
31                          , cast(o.currency as string)
32                          , sh.shop_currency
33                          , cast(
34                                o.current_total_price_set_presentment_money_currency_code as string
35                            )
36                          , cast(o.presentment_currency as string)
37                          , '{{ var("default_currency", "USD") }}'
38                        )
39                    )
40                  , ''
41                )
42            ) as shop_currency
43          , o.created_at
44          , lower(cast(o.financial_status as string)) as financial_status
45          , lower(cast(o.fulfillment_status as string)) as fulfillment_status
46          , upper(
47                nullif(
48                    trim(cast(o.shipping_address_country_code as string))
49                  , ''
50                )
51            ) as shipping_country_code
52          , upper(
53                nullif(
54                    trim(cast(o.billing_address_country_code as string))
55                  , ''
56                )
57            ) as billing_country_code
58          , cast(o.cancelled_at as timestamp) as cancelled_at
59        from
60            {{ ref('stg_shopify__order') }} o
61            left join shop sh on sh.shopify_shop = cast(o.shopify_store as string)
62        where
63            lower(cast(o.financial_status as string)) not in ('pending', 'voided', 'expired')
64            and o.test = false
65    )

Three decisions embedded there, all of which matter:

  • test = FALSE — test orders from theme development and gateway testing are real rows in raw data, and Shopify's reports exclude them. Miss this and your early history is junk.
  • Excluding pending, voided, expired — these never became revenue. Note that refunded and partially_refunded orders stay in: their refunds show up as RETURN events on the refund date, which is exactly what you want.
  • Currency precedence — the order's own currency beats the shop's current currency. This is the nastiest multi-currency bug: if your store ever changed base currency, or you migrated orders in from another platform, those historical orders carry a different currency, and defaulting to today's shop currency mislabels them by the full FX factor.

Step 2 — The event log

1agreements as (
2    select
3        cast(shopify_store as string) as shopify_store
4      , cast(id as string) as order_agreement_id
5      , cast(order_id as int64) as order_id
6      , cast(happened_at as timestamp) as happened_at
7      , lower(cast(app_handle as string)) as app_handle
8      , cast(reason as string) as reason
9    from
10        {{ ref('stg_shopify__order_agreement') }}
11    where
12        lower(reason) != 'voided'
13)
14  , sales as (
15    select
16        cast(shopify_store as string) as shopify_store
17      , cast(order_id as int64) as order_id
18      , cast(order_agreement_id as string) as order_agreement_id
19      , upper(cast(line_type as string)) as line_type
20      , upper(cast(action_type as string)) as action_type
21      , cast(quantity as int64) as quantity
22      , -- Shop money = the store's own base currency
23        cast(total_amount_shop_money_amount as numeric) as total_amount
24      , cast(total_tax_amount_shop_money_amount as numeric) as total_tax
25      , cast(
26            total_discount_amount_before_taxes_shop_money_amount as numeric
27        ) as discount_before_tax
28      , -- Presentment money = what the customer actually saw and paid
29        cast(total_amount_presentment_money_amount as numeric) as presentment_total_amount
30      , cast(
31            total_tax_amount_presentment_money_amount as numeric
32        ) as presentment_total_tax
33      , cast(
34            total_discount_amount_before_taxes_presentment_money_amount as numeric
35        ) as presentment_discount_before_tax
36      , upper(
37            cast(
38                total_amount_presentment_money_currency_code as string
39            )
40        ) as presentment_currency
41    from
42        {{ ref('stg_shopify__order_agreement_sale') }}
43)

reason = 'voided' agreements are cancellations of the agreement itself - they never represented money. Excluding them at the source is cleaner than netting them out later.

Step 3 — Join and localise the date

1-- Earliest ORDER agreement per order - used to detect checkout-flow edits
2order_first_time as (
3    select
4        shopify_store
5      , order_id
6      , min(happened_at) as order_created_at
7    from
8        agreements
9    where
10        lower(reason) = 'order'
11    group by
12        1
13      , 2
14)
15  , joined as (
16    select
17        -- Localise BEFORE truncating to a date. This is not optional.
18        date(datetime(a.happened_at, '{{ var("report_timezone", "UTC") }}')) as event_day
19      , a.shopify_store
20      , a.order_id
21      , a.order_agreement_id
22      , o.location_id
23      , o.source_name
24      , s.line_type
25      , s.action_type
26      , a.reason
27      , coalesce(s.quantity, 0) as quantity
28      , coalesce(s.total_amount, 0) as total_amount
29      , coalesce(s.total_tax, 0) as total_tax
30      , coalesce(s.discount_before_tax, 0) as discount_before_tax
31      , (
32            coalesce(s.total_amount, 0) - coalesce(s.total_tax, 0)
33        ) as amount_ex_tax
34      , -- Presentment equivalents, carried in parallel
35        coalesce(s.presentment_total_amount, 0) as presentment_total_amount
36      , coalesce(s.presentment_total_tax, 0) as presentment_total_tax
37      , coalesce(s.presentment_discount_before_tax, 0) as presentment_discount_before_tax
38      , s.presentment_currency
39      , -- Checkout-flow edit: an ORDER_EDIT within 90 seconds of order creation is
40        -- part of the original purchase (upsell widget, post-purchase offer), not a
41        -- later restatement of the order.
42        case
43            when a.reason = 'ORDER_EDIT'
44            and timestamp_diff(a.happened_at, oft.order_created_at, second) <= 90 then true
45            else false
46        end as is_checkout_edit
47    from
48        agreements a
49        join sales s on s.order_agreement_id = a.order_agreement_id
50        and s.order_id = a.order_id
51        and s.shopify_store = a.shopify_store
52        join orders o on o.order_id = a.order_id
53        and o.shopify_store = a.shopify_store
54        left join order_first_time oft on oft.order_id = a.order_id
55        and oft.shopify_store = a.shopify_store
56)
57  , typed as (
58    select
59        *
60      , case
61            when action_type in ('ORDER', 'UPDATE') then 'SALE'
62            when action_type = 'RETURN' then 'RETURN'
63            else 'OTHER'
64        end as report_row_type
65    from
66        joined
67    where
68        action_type in ('ORDER', 'UPDATE', 'RETURN')
69)

amount_ex_tax = total_amount - total_tax is the workhorse expression. Shopify's total_amount on an agreement sale line is tax-inclusive; every sales component except taxes is tax-exclusive. Subtract once, here, and reuse.

The 90-second window is the kind of rule you only discover by diffing against the admin UI. Post-purchase upsells and checkout-flow adjustments arrive as ORDER_EDIT agreements seconds after the order, and Shopify's UI treats them as part of the order. A genuine order edit three days later is a restatement. Ninety seconds separates the two cleanly in practice - tune it if your checkout has slower post-purchase flows.

Part 6 — Every sales component, in SQL

The core aggregation. Grain: day × store × order × location × channel × row type.

Keeping report_row_type in the grain (rather than collapsing to one row per order-day) means a day where an order both sells and refunds produces two rows, which keeps the SALE and RETURN sides independently auditable.

Orders count

1max(
2    case
3        when report_row_type = 'SALE'
4        and line_type = 'PRODUCT'
5        and action_type = 'ORDER'
6        and reason not in ('RETURN', 'ORDER_EDIT') then 1
7        else 0
8    end
9) as orders

MAX not SUM - one order is one order regardless of how many product lines it has. Excluding ORDER_EDIT prevents an edited order being counted a second time on the edit date.

For a clean AOV denominator that counts every order exactly once across its whole lifetime, add a window-function flag in the final SELECT:

1-- Flags exactly ONE row per order, on its first SALE day. Summing this over any
2-- window gives "orders placed" - the denominator Shopify uses for AOV. Counts
3-- edit-only orders that have no ORDER row, ignores return-only orders, and
4-- never double-counts across locations or days.
5case
6    when report_row_type = 'SALE'
7    and row_number() over (
8        partition by
9            shopify_store
10          , order_id
11        order by
12            case
13                when report_row_type = 'SALE' then 0
14                else 1
15            end
16          , day_date asc
17          , location_id asc
18          , source_name asc
19          , reason asc
20    ) = 1 then 1
21    else 0
22end as is_order_placed

Quantity — three variants, and why you need all three

1-- 1. Net quantity: sales minus returns. The "real" unit movement.
2sum(
3    case
4        when report_row_type = 'SALE'
5        and line_type in ('PRODUCT', 'GIFT_CARD') then quantity
6        when report_row_type = 'RETURN'
7        and line_type in ('PRODUCT', 'ADJUSTMENT') then quantity
8        else 0
9    end
10) as quantity
11  , -- 2. Strict "units ordered": excludes returns AND all order edits.
12--    Matches the Shopify admin UI's units-ordered figure.
13sum(
14    case
15        when report_row_type = 'SALE'
16        and line_type = 'PRODUCT'
17        and action_type = 'ORDER'
18        and coalesce(reason, '') not in ('RETURN', 'ORDER_EDIT') then quantity
19        else 0
20    end
21) as quantity_ordered_shopify_compat
22  , -- 3. Export parity: keeps checkout-flow edits, drops post-order edits.
23--    Matches Shopify's CSV export, which differs from the UI. Yes, really.
24sum(
25    case
26        when report_row_type = 'SALE'
27        and line_type in ('PRODUCT', 'GIFT_CARD')
28        and action_type = 'ORDER'
29        and coalesce(reason, '') != 'RETURN'
30        and (
31            coalesce(reason, '') != 'ORDER_EDIT'
32            or is_checkout_edit
33        ) then quantity
34        else 0
35    end
36) as quantity_ordered_shopify_export_parity

Shopify's UI and its CSV export do not always agree on units. Rather than picking a winner, materialise both and document which is which. When finance asks why this doesn't match the export, you have an answer and a column.

Net sales, returns, discounts

1sum(
2    case
3        when report_row_type = 'SALE'
4        and line_type = 'PRODUCT' then amount_ex_tax
5        when report_row_type = 'RETURN'
6        and line_type in ('PRODUCT', 'ADJUSTMENT') then amount_ex_tax
7        else 0
8    end
9) as net_sales
10  , sum(
11    case
12        when report_row_type = 'RETURN'
13        and line_type in ('PRODUCT', 'ADJUSTMENT') then amount_ex_tax
14        else 0
15    end
16) as returns
17  , -- Split out separately: useful for diagnosing "unattributed" refund value
18sum(
19    case
20        when report_row_type = 'RETURN'
21        and line_type = 'ADJUSTMENT' then amount_ex_tax
22        else 0
23    end
24) as return_adjustments
25  , (-1) * sum(
26    case
27        when report_row_type = 'SALE'
28        and line_type = 'PRODUCT' then greatest(discount_before_tax, 0)
29        else 0
30    end
31) as discounts

RETURN rows carry negative total_amount, so they reduce net sales automatically. ADJUSTMENT lines are return-side amounts Shopify can't attribute to a specific product line (partial refunds, goodwill credits) - including them is what makes returns tie out. Returns land on the refund date, not the order date; that's the whole point of the event grain and what makes your daily chart match the admin.

On discounts, two details: GREATEST(x, 0) guards against negative discount values, which appear on some edit and return events and would otherwise inflate the total; (-1) * enforces the sign convention.

Gross sales, shipping, duties, fees, taxes, totals

1-- In the final SELECT, derived from the aggregates. Since discounts and
2-- returns are both stored negative, this ADDS them back.
3(net_sales - discounts - returns) as gross_sales
4  , -- No report_row_type filter on these four: shipping and fees can occur on both
5-- SALE and RETURN events, and both belong in the totals. A refunded shipping
6-- charge arrives as a SHIPPING line on a RETURN action and correctly reduces
7-- the shipping total.
8sum(
9    case
10        when line_type = 'SHIPPING' then amount_ex_tax
11        else 0
12    end
13) as shipping_charges
14  , sum(
15    case
16        when line_type = 'DUTY' then amount_ex_tax
17        else 0
18    end
19) as duties
20  , sum(
21    case
22        when line_type = 'FEE' then amount_ex_tax
23        else 0
24    end
25) as return_fees
26  , cast(0 as numeric) as additional_fees
27  , sum(total_tax) as taxes
28  , (
29    net_sales + shipping_charges + duties + return_fees + additional_fees + taxes
30) as total_shopify_sales
31  , (
32    net_sales + shipping_charges + return_fees + additional_fees
33) as total_sales

additional_fees is hardcoded to zero for most stores. Keep the column so the schema stays stable and the sales equation stays complete; populate it if your market has regulatory fees.

Reversal columns (the returns detail block)

Shopify's reports include a returns breakdown. These columns let you build it without a second model:

1sum(
2    case
3        when report_row_type = 'RETURN'
4        and line_type in ('PRODUCT', 'ADJUSTMENT') then amount_ex_tax
5        else 0
6    end
7) as sales_reversals
8  , (-1) * sum(
9    case
10        when report_row_type = 'RETURN'
11        and line_type = 'PRODUCT' then discount_before_tax
12        else 0
13    end
14) as discount_reversals
15  , sum(
16    case
17        when report_row_type = 'RETURN' then total_tax
18        else 0
19    end
20) as tax_reversals
21  , sum(
22    case
23        when report_row_type = 'RETURN'
24        and line_type = 'SHIPPING' then amount_ex_tax
25        else 0
26    end
27) as shipping_reversals
28  , sum(
29    case
30        when report_row_type = 'RETURN'
31        and line_type in ('PRODUCT', 'ADJUSTMENT') then quantity
32        else 0
33    end
34) as reversed_quantity

And in the final SELECT:

1sales_reversals as net_sales_reversals
2  , sales_reversals - discount_reversals as gross_sales_reversals
3  , sales_reversals + tax_reversals + shipping_reversals + return_fees as total_sales_reversals

Gift cards — keep them separate

Gift card sales are not product sales. Selling a gift card is deferred revenue; redeeming it is a discount on a future order. Shopify keeps them out of the sales equation, and so should you:

1sum(
2    case
3        when line_type = 'GIFT_CARD' then (
4            amount_ex_tax - (-1) * greatest(discount_before_tax, 0)
5        )
6        else 0
7    end
8) as gift_card_gross_sales
9  , sum(
10    case
11        when line_type = 'GIFT_CARD' then amount_ex_tax
12        else 0
13    end
14) as gift_card_net_sales
15  , (-1) * sum(
16    case
17        when line_type = 'GIFT_CARD' then greatest(discount_before_tax, 0)
18        else 0
19    end
20) as gift_card_discounts
21  , sum(
22    case
23        when line_type = 'GIFT_CARD' then total_tax
24        else 0
25    end
26) as gift_card_taxes
27  , -- Convenience column: tax on gift cards behaves differently by jurisdiction
28taxes - gift_card_taxes as taxes_excluding_gift_cards

The complete component reference

Output columnSource rowsSign
ordersMAX over SALE + PRODUCT + ORDER, excluding editspositive
is_order_placedwindow flag, first SALE day per order0/1
quantitySALE (PRODUCT, GIFT_CARD) + RETURN (PRODUCT, ADJUSTMENT)net
quantity_ordered_shopify_compatSALE + PRODUCT + ORDER, no edits or returnspositive
quantity_ordered_shopify_export_parityas above, keeping checkout editspositive
gross_salesnet_sales − discounts − returnspositive
discountsSALE + PRODUCT, GREATEST(discount, 0)negative
returnsRETURN + (PRODUCT, ADJUSTMENT)negative
return_adjustmentsRETURN + ADJUSTMENT onlynegative
net_salesSALE + PRODUCT, plus RETURN + (PRODUCT, ADJUSTMENT)net
shipping_chargesany SHIPPING linenet
dutiesany DUTY linenet
return_feesany FEE linepositive
additional_feesconstant 0 unless your market needs itpositive
taxesSUM(total_tax) across all linesnet
total_shopify_salesnet + shipping + duties + fees + taxesnet
total_salesnet + shipping + fees (no tax, no duties)net
sales_reversalsreversed_quantityRETURN rows by line typenegative
gift_card_*GIFT_CARD lines, excluded from product salespositive

Part 7 — The implementation gotchas that cost you a week

These separate a model that's "roughly right" from one finance signs off on. For the conceptual version of why Shopify and your warehouse disagree in the first place - attribution windows, event aggregation, metric definitions - see Shopify analytics discrepancies. What follows is the SQL-level fix for each.

1. Timezone: localise before you truncate

happened_at is UTC. Shopify's reports use the store's timezone. If you DATE(happened_at) directly, every order placed after your local midnight offset lands on the wrong day - a systematic daily drift that averages out over a month but makes daily comparison impossible and breaks day-of-week analysis.

1-- WRONG
2date(happened_at)
3-- RIGHT
4date(datetime(happened_at, '{{ var("report_timezone") }}'))

Set the timezone once as a dbt var and reference it everywhere. Never inline it.

2. Order edits: three different behaviours

ScenarioShopify UIWhat to do
Post-purchase upsell, seconds after checkoutPart of the original orderInclude via is_checkout_edit
Support agent adds an item 3 days laterNew value on the edit dateInclude as a SALE on the edit date, exclude from "orders placed"
Edit that removes an itemNegative value on the edit dateInclude; it correctly reduces that day's sales

That's the reason for the parity columns:

1-- Shopify-parity net sales: exclude POST-ORDER edits, keep checkout edits
2sum(
3    case
4        when report_row_type = 'SALE'
5        and line_type = 'PRODUCT'
6        and (
7            coalesce(reason, '') != 'ORDER_EDIT'
8            or is_checkout_edit
9        ) then amount_ex_tax
10        when report_row_type = 'RETURN'
11        and line_type in ('PRODUCT', 'ADJUSTMENT') then amount_ex_tax
12        else 0
13    end
14) as net_sales_shopify_parity

Ship both net_sales (everything, correct for finance) and net_sales_shopify_parity (matches the UI, correct for reconciliation).

3. Refund vs return vs cancellation

Shopify's data lumps these together; your business doesn't. order_line_refund.restock_type distinguishes them:

1refund_reversal_type_ranked as (
2    select
3        event_day
4      , shopify_store
5      , order_id
6      , case
7            when restock_type = 'cancel' then 'CANCELLATION' -- never shipped
8            when restock_type = 'return' then 'RETURN' -- came back, restocked
9            else 'REFUND' -- money back, no restock
10        end as reversal_type
11    from
12        refund_reversal_type
13        -- Dominant restock type wins when a refund mixes them
14    qualify
15        row_number() over (
16            partition by
17                event_day
18              , shopify_store
19              , order_id
20            order by
21                qty desc
22              , restock_type
23        ) = 1
24)

Return rate calculated on RETURN alone is a product-quality signal. Calculated on all three it's noise - cancellations say something about fulfillment speed, not product fit.

4. Blank product titles

Some orders carry lines with no title - deleted products, custom line items from draft orders, API artifacts. Shopify's product reports drop them. Filter at the order level so partial orders don't half-appear:

1order_product_title_flag as (
2    select
3        cast(shopify_store as string) as shopify_store
4      , cast(order_id as int64) as order_id
5      , max(
6            case
7                when coalesce(cast(gift_card as bool), false) then 1
8                when trim(
9                    coalesce(cast(title as string), cast(name as string), '')
10                ) != '' then 1
11                else 0
12            end
13        ) as has_product_title
14    from
15        {{ ref('stg_shopify__order_line') }}
16    group by
17        1
18      , 2
19)
20-- ... then in the final query:
21where
22    coalesce(opt.has_product_title, 0) = 1

5. Fulfillment status is NULL for digital goods

An order of only digital products never gets a fulfillment status, which reads as "unfulfilled" in every dashboard and panics ops. Distinguish it:

1case
2    when fulfillment_status is null
3    and coalesce(order_any_line_requires_shipping, true) = false then 'fulfillment_not_required'
4    else fulfillment_status
5end as fulfillment_status

fed by a LOGICAL_OR(COALESCE(requires_shipping, TRUE)) rollup of order_line per order.

6. Cancelled orders

cancelled_at doesn't remove sales value - a cancelled order that was refunded produces RETURN events that net it out. Expose the flag and let consumers filter:

1datetime(cancelled_at, '{{ var("report_timezone") }}') as cancelled_at
2  , cancelled_at is not null as is_cancelled

Don't filter cancelled orders out of the model. If you do, your sales-over-time chart stops matching Shopify and you can't explain the gap.

Part 8 — Multi-currency: shop money vs presentment money

Shopify records every amount twice: shop money (the store's base currency) and presentment money (the currency the customer actually saw and paid in).

The double-conversion trap

Say your store's base currency is GBP and a customer checks out in EUR. Shopify records shop money in GBP and presentment money in EUR. If you want to report in EUR and you convert GBP → EUR at your own daily rate, you get a number that doesn't match what the customer paid, because Shopify used its own rate at checkout.

The fix: when the presentment currency already equals your target currency, use the presentment amount directly. Only convert when it doesn't.

An alternative that scales better across many currencies is to derive Shopify's own implied FX rate per order and reuse it:

1order_presentment as (
2    select
3        shopify_store
4      , order_id
5      , any_value(presentment_currency) as presentment_currency
6      , safe_divide(
7            sum(presentment_total_amount)
8          , nullif(sum(total_amount), 0)
9        ) as shopify_fx_rate -- presentment per 1 unit of shop money
10    from
11        agreement_sales
12    group by
13        1
14      , 2
15)

Then every conversion becomes:

1coalesce(
2    case
3        when presentment_currency = 'EUR' then net_sales * shopify_fx_rate
4    end
5  , safe_divide(net_sales * shop_to_base, eur_rate)
6) as net_sales_eur

This uses Shopify's actual checkout rate when the target currency matches what the customer paid in, and falls back to your daily reference rate otherwise. Cross-currency totals then tie to the payment processor far more closely.

Nearest-day FX with no gaps

FX feeds skip weekends and holidays. A naive equi-join on date silently NULLs those rows and quietly deletes a large slice of your converted revenue.

1fx_shop_to_base_ranked as (
2    select
3        dc.rate_day
4      , dc.shop_currency
5      , case
6            when p.source_currency = dc.shop_currency
7            and p.target_currency = 'BASE' then p.exchange_rate
8            when p.source_currency = 'BASE'
9            and p.target_currency = dc.shop_currency then safe_divide(1, p.exchange_rate) -- invert if only the reverse pair exists
10        end as shop_to_base
11      , row_number() over (
12            partition by
13                dc.rate_day
14              , dc.shop_currency
15            order by
16                abs(date_diff(p.rate_day, dc.rate_day, day))
17              , p.rate_day desc
18        ) as rn
19    from
20        fx_day_currency dc
21        join fx_pairs_by_day p on dc.shop_currency != 'BASE'
22        and (
23            (
24                p.source_currency = dc.shop_currency
25                and p.target_currency = 'BASE'
26            )
27            or (
28                p.source_currency = 'BASE'
29                and p.target_currency = dc.shop_currency
30            )
31        )
32)
33  , fx_shop_to_base as (
34    select
35        rate_day
36      , shop_currency
37      , cast(1 as numeric) as shop_to_base
38    from
39        fx_day_currency
40    where
41        shop_currency = 'BASE'
42    union all
43    select
44        rate_day
45      , shop_currency
46      , shop_to_base
47    from
48        fx_shop_to_base_ranked
49    where
50        rn = 1
51)

Three things this handles that a naive join doesn't: nearest-day fallback (weekends and holidays), rate inversion (your feed may only carry one direction), and identity (base currency to itself is 1, not a lookup).

One BigQuery-specific note: resolving several currency pairs in separate ranked CTEs can blow the query planner's complexity limit on large tables. Rank all target currencies in a single pass instead.

Part 9 — Testing and validating against Shopify

A sales model nobody has reconciled is a rumour. Here's the validation ladder.

Level 1 — internal consistency (dbt tests)

1version: 2
2
3models:
4  - name: core_shopify__sales_over_time
5    tests:
6      - dbt_utils.unique_combination_of_columns:
7          combination_of_columns:
8            - date
9            - shopify_store
10            - order_id
11            - location_id
12            - source_name
13            - report_row_type
14    columns:
15      - name: date
16        tests: [not_null]
17      - name: shopify_store
18        tests: [not_null]
19      - name: net_sales
20        tests: [not_null]
21      - name: discounts
22        tests:
23          - dbt_utils.expression_is_true:
24              expression: "<= 0"        # sign convention enforced
25      - name: returns
26        tests:
27          - dbt_utils.expression_is_true:
28              expression: "<= 0"
29

Level 2 — the sales equation holds

1-- Should return zero rows
2select
3    date
4  , shopify_store
5  , sum(net_sales) as net_sales
6  , sum(gross_sales + discounts + returns) as recomputed
7from
8    {{ ref('core_shopify__sales_over_time') }}
9group by
10    1
11  , 2
12having
13    abs(
14        sum(net_sales) - sum(gross_sales + discounts + returns)
15    ) > 0.01

Level 3 — reconcile against Shopify itself

The one that actually matters. Export a month of the Sales report from the Shopify admin, load it into a seed table, and diff:

1with
2    mine as (
3        select
4            date
5          , sum(gross_sales_shopify_parity) as gross_sales
6          , sum(discounts_shopify_parity) as discounts
7          , sum(returns) as returns
8          , sum(net_sales) as net_sales
9          , sum(total_shopify_sales) as total_sales
10        from
11            {{ ref('core_shopify__sales_over_time') }}
12        where
13            shopify_store = 'store_1'
14            and date between '2026-01-01' and '2026-01-31'
15        group by
16            1
17    )
18  , theirs as (
19        select
20            *
21        from
22            {{ ref('shopify_export_january') }}
23    )
24select
25    m.date
26  , m.net_sales as mine
27  , t.net_sales as shopify
28  , round(m.net_sales - t.net_sales, 2) as diff
29  , round(
30        safe_divide(m.net_sales - t.net_sales, nullif(t.net_sales, 0)) * 100
31      , 2
32    ) as diff_pct
33from
34    mine m
35    join theirs t using (date)
36order by
37    abs(m.net_sales - t.net_sales) desc

Reading the diffs

SymptomAlmost certainly
Uniform small daily offset, cancels over a monthTimezone not localised before DATE()
Your numbers consistently higherTest orders not excluded, or pending/voided included
Returns land on the wrong daysAttributing returns to order date instead of refund date
Gross sales too high, net correctTax not subtracted - check amount_ex_tax
Units off but revenue rightOrder-edit handling - compare the parity quantity columns
Off by an exact FX factor on a subsetCurrency resolution - orders denominated in an old base currency
One day wildly offA single large order edit or refund - find it and trace it end to end

Expect to land within 0.5% on the first serious pass and within 0.05% after handling edits and timezone. Exact-to-the-cent parity across all history is not a realistic goal; Shopify restates some historical figures and rounds in places you can't observe. Agree an acceptable tolerance with finance up front and monitor it.

Part 10 — Performance and materialization

These models scan every order event you've ever had. Configure accordingly.

1{{ config(
2    materialized='table',
3    partition_by={'field': 'date', 'data_type': 'date', 'granularity': 'month'},
4    cluster_by=['shopify_store']
5) }}
6
  • Monthly partitions, not daily. These are moderate-sized tables queried in month and quarter ranges. Daily partitioning creates thousands of small partitions and metadata overhead that costs more than it saves.
  • Full-refresh tables beat incremental - until they don't. Returns, edits and refunds mutate history, so incremental models need a lookback window and merge logic. Below roughly 50M order-event rows a nightly full rebuild is simpler, cheaper in engineering time, and immune to drift. Above that, go incremental on date with a 90-day lookback.
  • Materialize hot staging models as tables. order_agreement_sale gets referenced many times; as a view it re-scans raw every single time.
  • Watch the planner on wide CTE chains. BigQuery has a query-complexity ceiling. If you hit it, the usual culprit is many near-identical ranked CTEs - collapse them into one ranked pass, as in the FX section.
  • Keep the analytics layer thin. A SELECT * view over a materialized core table costs nothing and gives you a stable contract to refactor behind.

The order to build in

  1. Staging models for the eight core tables. One evening.
  2. sales_over_time with just net sales. Reconcile that one number against the admin before adding anything else.
  3. Add components one at a time - discounts, returns, shipping, taxes - reconciling after each. Adding all of them then debugging is how a two-day job becomes a two-week job.
  4. Handle the gotchas: timezone first, then test orders, then order edits.
  5. Add currencies once single-currency numbers are signed off.
  6. Build the product-level model at order × line × day grain, and immediately add a test that diffs it against the order-level model daily.
  7. Layer COGS and profit on top.

The payoff is that "what were sales last Tuesday" and "what's the fully-loaded margin on SKU X in Q3" become the same query against the same grain - and both tie to the number in the Shopify admin.

Running dbt on top of your Shopify sync

The models are only useful if they run after fresh data lands. Weld's Shopify connector syncs the raw streams, and the dbt Cloud integration triggers your dbt jobs directly after each sync completes, so the sales report rebuilds on the same schedule your data arrives on. If you'd rather not run dbt at all, Weld transforms with GitHub Sync give you push-to-deploy SQL models with lineage and no dbt project to maintain - that's the path the standalone SQL guide takes.

FAQ: Shopify dbt models

Is there a dbt package for Shopify?

There are community packages, and they're a reasonable starting point for order and customer models. Where they tend to fall short is exactly what this guide covers: the agreement event grain, order-edit parity with the admin UI, multi-store unions with schema drift, and presentment-vs-shop-money currency handling. If you need finance sign-off, expect to own the sales model yourself.

Should I use order_line or order_agreement_sale?

order_agreement_sale, for anything time-based. order_line describes an order's current state and cannot express when value changed, so returns, edits and cancellations can't be attributed to the day they happened. Use order_line only for product identity - SKU, title, variant, vendor.

Why don't my dbt numbers match the Shopify admin exactly?

The usual four, in order of frequency: (1) timezone - happened_at is UTC and must be converted to the store's timezone before truncating to a date; (2) test orders not excluded via test = FALSE; (3) orders with financial_status of pending, voided or expired included; (4) order edits handled differently than Shopify handles them. Fix those and you should be inside 0.5%.

What's the difference between gross sales and net sales in Shopify?

Gross sales is product price × quantity before any reduction - no discounts, no returns, no tax, no shipping. Net sales is gross sales minus discounts and minus returns, still excluding tax and shipping. Net sales is the closest thing Shopify reports to "revenue" in the accounting sense.

What's the difference between total sales and net payout?

Total sales is net sales plus shipping, duties, fees and taxes. Your payout is total sales minus payment processing fees, minus refunds already paid out, minus chargebacks, adjusted for your processor's settlement timing. They will never match without a reconciliation model that brings in transaction and processor payout data.

How do I report on multiple Shopify stores together in dbt?

Union the raw tables in staging with a shopify_store key derived from the source schema, use a column-union macro so schema drift between stores doesn't break the build, and include shopify_store in every downstream join key. Order IDs are only unique within a store.

Which currency amounts should I use — shop money or presentment money?

Both. Shop money is the store's base currency and is what Shopify's own reports use. Presentment money is what the customer actually paid. Report in shop money by default, and use presentment money directly whenever the presentment currency equals your target reporting currency - that avoids double-converting through your own FX rate and keeps totals close to processor settlement.

How do I handle returns in a Shopify sales report?

Attribute them to the refund date, not the original order date, and store them as negative values so they flow through net sales arithmetically. That's how Shopify does it, and it's why your daily chart shouldn't change retroactively when someone processes an old refund.

Are gift cards included in sales?

No. A gift card sale is deferred revenue, not product revenue; the sale is recognised when the card is redeemed. Track gift card gross sales, net sales, discounts and taxes in separate columns so you can report on them without contaminating the sales equation.

Should Shopify sales models be incremental?

Not until you have to. Returns, edits and refunds mutate historical rows, so incremental builds need a lookback window and merge logic that can drift. Below roughly 50M order-event rows a nightly full rebuild is simpler and cheaper overall. Above that, go incremental on date with a 90-day lookback and full-refresh monthly.