ds@mcpanalytics.aiEmail your data and your question and get an answer about your own data, with the method shown.Email your data and question. Get an answer.

How to Use Digital vs Physical Product Analysis in Etsy: Step-by-Step Tutorial

Updated October 2026

Category: Etsy Analytics | Updated: October 2026

Introduction: Why Digital vs Physical Product Analysis Matters

As an Etsy seller, one of the most critical strategic decisions you'll face is determining the optimal mix between digital and physical products. Should you invest more time creating digital downloads that carry no per-unit materials or shipping cost, or focus on handcrafted physical items that command premium prices? How significantly do shipping costs erode your physical product margins?

These questions don't have universal answers. They depend entirely on your unique business data. That's where systematic digital versus physical product analysis becomes invaluable. By examining your actual sales performance across both product categories, you can make evidence-based decisions that maximize profitability and align with your business goals.

This tutorial compares:

Whether you're running a shop that offers both product types or considering adding one category to complement the other, this analysis will provide the clarity you need to optimize your Etsy business strategy.

Step 1: Prerequisites and Data Requirements

This tutorial compares your digital and physical products on revenue, cost, profit and orders. All code uses pandas, and every figure on the page comes from a synthetic data set of 896 orders over one year that was generated for this tutorial. It is not from a real shop, so your numbers will differ.

What You Need

Digital and Physical Products

Digital products are items delivered as a file: printable art, planners, SVG files, patterns, templates, fonts, worksheets. Physical products are items you make or source and ship. Items that are both (an art print sold as a file or as a shipped print) need to be classified by what the buyer ordered.

Exporting Your Etsy Orders

Etsy lets sellers download sold orders as a CSV from Shop Manager. We could not check Etsy's current help pages when updating this tutorial, so treat the menu path, the file name and the column names as assumptions to check against your own account and file. Confirm that the file has an order ID, a date, a quantity and a price per item, and check whether the price includes or excludes shipping. An order export is unlikely to contain your own costs, buyer identifiers, reviews, returns or delivery times; the analysis below uses only data you can supply.

Classifying by title: if you label products from titles with keywords, review the result by hand, because a title containing "download" can still be a physical item.

Step 2: Build the Order Table

The block below builds the synthetic orders. It prints the number of orders in each group and the total, which should equal the number of rows in your file. Replace the generator with your own data when you run it.

import numpy as np
import pandas as pd

# Synthetic orders generated for this tutorial. Replace this block with your own order export,
# renamed to these columns. item_price is the price per item; shipping_cost is what YOU paid
# the carrier for that order (from your own records or shipping labels).
rng = np.random.default_rng(11)
n_dig, n_phy = 312, 584
orders = pd.DataFrame({
    'order_id': np.arange(1, n_dig + n_phy + 1),
    'product_type': ['Digital'] * n_dig + ['Physical'] * n_phy,
})
orders['order_date'] = pd.Timestamp('2024-01-01') + pd.to_timedelta(rng.integers(0, 366, len(orders)), unit='D')
orders['quantity'] = np.where(orders['product_type'] == 'Digital',
                              rng.choice([1, 2, 3], len(orders), p=[0.4, 0.4, 0.2]),
                              rng.choice([1, 2], len(orders), p=[0.75, 0.25]))
orders['item_price'] = np.where(orders['product_type'] == 'Digital',
                                rng.gamma(6, 20 / 6, len(orders)), rng.gamma(6, 30 / 6, len(orders))).round(2)
orders['shipping_cost'] = np.where(orders['product_type'] == 'Digital', 0.0,
                                   rng.gamma(8, 5 / 8, len(orders))).round(2)
orders['revenue'] = (orders['item_price'] * orders['quantity']).round(2)
print(orders.groupby('product_type').size().rename('orders').to_frame().T.to_string())
print('total order rows:', len(orders))

Expected output:

product_type  Digital  Physical
orders            312       584
total order rows: 896

Everything that follows uses these 896 orders: 312 digital and 584 physical.

Step 3: Interpreting Your Results

Profit by Product Type

The next block applies cost assumptions to every order and totals revenue, each cost line and profit by type. The cost lines are: cost of goods (as a share of revenue), shipping you paid, packaging, Etsy fees and payment fees. The percentages and the packaging figure are assumptions for the illustration (10% and 45% cost of goods, 6.5% Etsy fee, 3% + 0.25 per order payment fee, 0.80 packaging per physical order). Replace them with your own; take fee rates from Etsy's fee pages and your payment account statements.

# Cost assumptions: replace with your own. The fee rates are placeholders; take yours from Etsy's fee pages
# and your payment account statements.
COGS_PCT = {'Digital': 0.10, 'Physical': 0.45}   # share of revenue
ETSY_FEE_PCT = 0.065
PAYMENT_PCT, PAYMENT_FIXED = 0.03, 0.25           # per order
PACKAGING_PER_ORDER = {'Digital': 0.0, 'Physical': 0.80}

orders['cogs'] = orders['revenue'] * orders['product_type'].map(COGS_PCT)
orders['etsy_fee'] = orders['revenue'] * ETSY_FEE_PCT
orders['payment_fee'] = orders['revenue'] * PAYMENT_PCT + PAYMENT_FIXED
orders['packaging'] = orders['product_type'].map(PACKAGING_PER_ORDER)
cost_cols = ['cogs', 'shipping_cost', 'packaging', 'etsy_fee', 'payment_fee']
orders['profit'] = orders['revenue'] - orders[cost_cols].sum(axis=1)

summary = orders.groupby('product_type')[['revenue'] + cost_cols + ['profit']].sum().T.round(2)
summary.loc['margin_pct'] = (100 * summary.loc['profit'] / summary.loc['revenue']).round(1)
print(summary.to_string())
print('revenue share:', (100 * summary.loc['revenue'] / summary.loc['revenue'].sum()).round(1).to_dict())

Expected output:

product_type    Digital  Physical
revenue        10996.34  22117.92
cogs            1099.63   9953.06
shipping_cost      0.00   2942.08
packaging          0.00    467.20
etsy_fee         714.76   1437.66
payment_fee      407.89    809.54
profit          8774.05   6508.37
margin_pct        79.80     29.40
revenue share: {'Digital': 33.2, 'Physical': 66.8}

Physical products bring in 66.8% of revenue (22,117.92 of 33,114.26), about twice the digital figure of 10,996.34. After the assumed costs, digital profit is 8,774.05 (a margin of 79.8%) and physical profit is 6,508.37 (29.4%), so in this example digital products earn more profit on half the revenue. Part of that gap comes from the cost assumptions themselves: the cost of goods line differs by 8,853 (9,953.06 against 1,099.63); 7,741 of that comes from the 45% against 10% rates and the remaining 1,112 from physical revenue being about twice as large, so change those inputs and the answer changes. The table includes the costs listed above and no others: it has no allowance for your time on listings, advertising or overhead.

Per-Order Metrics

per = orders.groupby('product_type').agg(orders=('order_id', 'count'), items=('quantity', 'sum'),
                                         revenue=('revenue', 'sum'), profit=('profit', 'sum'))
per['avg_order_value'] = (per['revenue'] / per['orders']).round(2)
per['items_per_order'] = (per['items'] / per['orders']).round(2)
per['profit_per_order'] = (per['profit'] / per['orders']).round(2)
print(per[['orders', 'avg_order_value', 'items_per_order', 'profit_per_order']].T.to_string())

Expected output:

product_type      Digital  Physical
orders             312.00    584.00
avg_order_value     35.24     37.87
items_per_order      1.75      1.23
profit_per_order    28.12     11.14

Average order values are close (35.24 digital, 37.87 physical), but that does not say what buyers would pay for the two kinds of product; it says what these baskets came to. Digital orders have more items per order (1.75 against 1.23). Profit per order is 28.12 against 11.14, which is the same finding as the margin table expressed per order.

Customer Behavior

Repeat purchase rate, review rate, return rate and fulfillment time are useful for this comparison, but they need data that an order export does not normally hold: a buyer identifier to count repeat buyers, review and return records, and delivery dates. If you have them, compute each for the two groups on the same basis, for example the share of buyers with two or more orders in the period, and compare with the order counts in mind. This tutorial does not give example figures for them, because the synthetic data set has none.

Monthly Revenue

If you have a full year, group revenue by month and type. The last line checks that the months add up to the total revenue.

monthly = (orders.assign(month=orders['order_date'].dt.to_period('M'))
           .pivot_table(index='month', columns='product_type', values='revenue', aggfunc='sum', fill_value=0)
           .round(2))
print(monthly.to_string())
print('sum of months equals total revenue:', bool(np.isclose(monthly.to_numpy().sum(), orders['revenue'].sum(), atol=0.05)))

Expected output:

product_type  Digital  Physical
month                          
2024-01       1192.60   1991.23
2024-02       1378.55   1800.51
2024-03        973.36   1773.46
2024-04        797.74   1759.07
2024-05        714.21   1887.66
2024-06        537.87   1584.27
2024-07        932.65   1793.17
2024-08        937.94   1901.04
2024-09        767.76   1932.08
2024-10        762.22   2069.56
2024-11       1082.43   2148.44
2024-12        919.01   1477.43
sum of months equals total revenue: True

The synthetic order dates are random, so this table has no seasonal pattern by construction. In your own data, look for months where one type moves differently from the other, and compare the same month across years if you have them before you plan around a season.

Step 4: Making Data-Driven Decisions

The tables describe what happened in the period you chose. They do not say what would happen if you changed your mix, so treat each use below as a hypothesis to test.

Scenario 1: Should I Offer More Digital Products?

Questions the data can inform:

Questions it cannot answer: whether more digital listings will sell as well as the ones you have, and whether your niche is crowded. A small test, such as launching two or three digital products and comparing the first months with your existing ones, is better evidence than a threshold on a margin.

Scenario 2: How Do Shipping Costs Affect Physical Margins?

Shipping you paid is a column in the table, so you can see how much of physical revenue it takes and what a change would do. The block below asks what the margin would be if shipping cost per order fell by 25%, everything else unchanged.

# What if shipping cost per physical order fell by 25%? Everything else unchanged.
phy = summary['Physical']
new_profit = phy['profit'] + 0.25 * phy['shipping_cost']
print('shipping as % of physical revenue:', round(100 * phy['shipping_cost'] / phy['revenue'], 1))
print('physical margin now:', phy['margin_pct'], '-> with 25% lower shipping:', round(100 * new_profit / phy['revenue'], 1))

Expected output:

shipping as % of physical revenue: 13.3
physical margin now: 29.4 -> with 25% lower shipping: 32.8

Shipping is 13.3% of physical revenue in the example, and a 25% reduction would lift the physical margin from 29.4% to 32.8%. The 25% is a what-if, not a promise that any carrier or package change delivers it. Ways to look for a saving include comparing carriers and labels, lighter packaging, and encouraging multi-item orders; build any saving into the numbers only once you have a quote.

Scenario 3: Balancing the Mix

Digital and physical products can support each other: a physical buyer may also want the digital version, and the reverse. If you have buyer identifiers, count how many buyers purchased both kinds. If you do not, you cannot measure it from an order export. For seasonal planning, use the monthly table from your own data for at least a year before changing what you launch when.

To test pricing changes or product descriptions systematically, see our guide to A/B testing and statistical significance.

Run It on Your Own Etsy Export

Export your orders from Etsy as a CSV and upload it at MCP Analytics. The analysis compares revenue and orders for digital and physical products on your file and shows the result with the method used. You choose the columns; nothing connects to your Etsy account. Add a product type column and your cost columns to the file first if you want profit and margin.

Try it on your data

Step 5: Next Steps with Etsy Analytics

Step 6: Common Issues and Solutions

Issue 1: Missing Product Type

Problem: Your export does not say whether an item is digital or physical.

Solution: Classify from the listing title or SKU with keywords, then review by hand. For example:

items = pd.DataFrame({'title': ['Printable Wall Art (digital download)', 'Hand-Poured Candle', 'Planner Template']})
items['product_type'] = items['title'].str.contains('digital|printable|download|template', case=False, regex=True).map({True: 'Digital', False: 'Physical'})
print(items.to_string(index=False))

Expected output:

                                 title product_type
 Printable Wall Art (digital download)      Digital
                    Hand-Poured Candle     Physical
                      Planner Template      Digital

Keyword rules misclassify some items, so read a sample of each group.

Issue 2: Shipping on Mixed Orders

Problem: An order contains both a digital and a physical item, and shipping is recorded for the whole order.

Solution: Digital items need no shipping, so assign the order's shipping cost to the physical items. If there are several physical items, split it by their value. State which rule you used next to the result.

Issue 3: No Exact Cost of Goods

Problem: You do not track cost per product.

Solution: Use rough figures from your receipts and time records, label them as estimates, and run the analysis at two or three different cost percentages to see how much the margin depends on them. Keep a simple log of costs per product from now on.

Issue 4: Too Few Orders

Problem: One of the groups has only a few dozen orders.

Solution: Use a longer date range, or compare at a coarser level, and read the margins as rough. Re-run when you have more orders.

Issue 5: Seasonal Business

Problem: Your sales are concentrated in a season, so a short window is not typical.

Solution: Use at least a full year, or compare the same months across years, and run peak and off-peak separately.

Issue 6: A Few Large Orders Skew the Averages

Problem: A few very large or small orders move the average order value.

Solution: Report the median next to the mean, and look at the largest orders by hand before deciding whether any of them is an error or a one-off:

print(orders.groupby('product_type')['revenue'].agg(['mean', 'median']).round(2).to_string())
print(orders.nlargest(5, 'revenue')[['order_id', 'product_type', 'revenue']].to_string(index=False))

Conclusion: Data-Driven Product Strategy

Revenue and profit can rank two product types in opposite orders. In the synthetic example physical products earn twice the revenue and digital products earn more profit, but only under the cost assumptions stated above, and only for costs that were counted. The method is what carries over to your shop: classify each order, count the costs you actually have, reconcile the totals, and say which inputs are assumptions. Then test any change you make instead of reading it off the table.

Explore more: Etsy Shop Analytics: all tools, tutorials, and guides →

Your turn

Bring your own data and the question you actually need answered.

MCP AnalyticsData science by email Send us your data and your question, and we will reply. ds@mcpanalytics.ai