Metric · E-commerce

What is inventory turnover, and how do you measure it in Metabase?

Inventory turnover is how many times per year you sell through your average stock — cost of goods sold ÷ average inventory value, annualized. It's the metric that connects merchandising to cash: every point of turnover you gain is working capital released from the warehouse. Measure it in Metabase from order and inventory data synced into a database (Shopify, WooCommerce, or Square).

TL;DR — turnover = COGS ÷ average inventory value, annualized; days of inventory = 365 ÷ turnover. It needs snapshots: a current-state stock sync can't reconstruct what inventory was last quarter, so start a daily snapshot job before you need the history.

How is inventory turnover calculated?

COGS over a period ÷ average inventory value across that period, scaled to a year (multiply a quarterly ratio by 4). The choices that matter:

  • COGS, not revenue — inventory is valued at cost, so a revenue numerator inflates the ratio by your markup.
  • Average inventory, not point-in-time — average the period's daily (or at least weekly) snapshot values, or a restock the day before quarter-end distorts everything.
  • Days of inventory (DSI) — 365 ÷ turnover converts the ratio into the operator's unit: how many days current stock lasts at the current sell-through rate.
  • Segment it — a healthy blended turnover routinely hides dead stock in one category funding fast movers in another.

What data does inventory turnover need?

  • Order line items with quantity and unit_cost for COGS (unit costs live on products or purchase orders — join them in).
  • An inventory_snapshots table: one row per product per day withon_hand and unit_cost, written by a scheduled job. Platform syncs usually carry only current stock, and history can't be backfilled.
  • Product category and SKU fields for segmentation and slow-mover lists.
  • Order status, so cancelled and refunded orders don't count as sell-through.

SQL patterns

Quarterly turnover, annualizedPostgreSQL

COGS per quarter over average snapshot value, scaled by 4.

-- Quarterly inventory turnover, annualized:
-- COGS from sold line items over average inventory value from daily snapshots.
WITH cogs AS (
  SELECT
    date_trunc('quarter', o.created_at)           AS quarter,
    SUM(li.quantity * li.unit_cost)               AS cogs
  FROM order_line_items li
  JOIN orders o ON o.id = li.order_id
  WHERE o.status NOT IN ('cancelled', 'refunded')
  GROUP BY date_trunc('quarter', o.created_at)
),
inventory AS (
  SELECT
    date_trunc('quarter', snapshot_date)          AS quarter,
    AVG(daily_value)                              AS avg_inventory_value
  FROM (
    SELECT
      snapshot_date,
      SUM(on_hand * unit_cost)                    AS daily_value
    FROM inventory_snapshots
    GROUP BY snapshot_date
  ) daily
  GROUP BY date_trunc('quarter', snapshot_date)
)
SELECT
  c.quarter,
  ROUND(c.cogs, 0)                                AS cogs,
  ROUND(i.avg_inventory_value, 0)                 AS avg_inventory_value,
  ROUND(4.0 * c.cogs / NULLIF(i.avg_inventory_value, 0), 2)
    AS annualized_turnover
FROM cogs c
JOIN inventory i ON i.quarter = c.quarter
ORDER BY c.quarter;
Days of inventory by categoryPostgreSQL

Rank categories by how long their stock lasts — the top of this list is your overstock.

-- Days of inventory (DSI) by category over the trailing 90 days:
-- how long the current stock would last at the recent sell-through rate.
WITH cogs AS (
  SELECT
    p.category,
    SUM(li.quantity * li.unit_cost)               AS cogs_90d
  FROM order_line_items li
  JOIN orders o   ON o.id = li.order_id
  JOIN products p ON p.id = li.product_id
  WHERE o.created_at >= CURRENT_DATE - INTERVAL '90 days'
    AND o.status NOT IN ('cancelled', 'refunded')
  GROUP BY p.category
),
inventory AS (
  SELECT
    category,
    AVG(daily_value)                              AS avg_inventory_value
  FROM (
    SELECT
      p.category,
      s.snapshot_date,
      SUM(s.on_hand * s.unit_cost)                AS daily_value
    FROM inventory_snapshots s
    JOIN products p ON p.id = s.product_id
    WHERE s.snapshot_date >= CURRENT_DATE - INTERVAL '90 days'
    GROUP BY p.category, s.snapshot_date
  ) daily
  GROUP BY category
)
SELECT
  i.category,
  ROUND(i.avg_inventory_value, 0)                 AS avg_inventory_value,
  ROUND(90.0 * i.avg_inventory_value / NULLIF(c.cogs_90d, 0), 0)
    AS days_of_inventory
FROM inventory i
JOIN cogs c ON c.category = i.category
ORDER BY days_of_inventory DESC;

Pitfalls

Using current stock levels as if they were history.→ A current-state sync overwrites yesterday. Without dated snapshots there is no average inventory, and the metric silently degrades into a guess.
Putting revenue in the numerator.→ Revenue ÷ cost-valued inventory overstates turnover by your margin. Use COGS, or label the card as a sales-to-stock ratio and don't call it turnover.
Reporting one blended ratio.→ Fast movers subsidize dead stock in the average. Break turnover down by category and keep a slow-mover list ranked by days of inventory.
Treating higher turnover as always better.→ Turnover can be raised by simply carrying too little stock — and the stockouts it causes never show up in the ratio. Watch stockout or lost-sales signals next to it.

Where this metric applies

Analytics

Metrics

FAQ

Should I use COGS or revenue in the numerator?
COGS. Inventory is carried at cost, so dividing revenue by cost-valued inventory inflates turnover by exactly your markup — a store with 60% margins would overstate turnover by 2.5x. Using COGS also keeps the metric honest across categories with different margins, and lets it sit consistently next to gross margin on the same dashboard.
What are days of inventory (DSI), and which should I report?
They're the same fact in two units: DSI = 365 ÷ annualized turnover. Turnover of 6 means roughly 61 days of inventory. Finance audiences tend to think in turnover; operators and buyers think in days, because "48 days of stock against a 30-day reorder lead time" is directly actionable. Compute turnover in the model and derive days in the same query — reporting both costs nothing.
What's a good inventory turnover ratio?
Entirely category-dependent — grocery turns dozens of times a year, furniture a handful. Higher is not automatically better either: pushing turnover up by cutting stock raises stockout risk, and lost sales never appear in the ratio. Track your own trend by category, watch slow movers, and read turnover next to sell-through and average order value on an e-commerce sales dashboard.
Why do I need inventory snapshots?
Because most platform syncs carry only the current stock level, and today's on-hand count tells you nothing about what average inventory was last quarter — you can't reconstruct history from current state. Schedule a daily job that copies stock levels into an inventory_snapshots table with a date column. The metric only becomes computable from the day you start snapshotting, so start before you need it.
How do you track inventory turnover in Metabase?
Sync orders, line items with unit costs, and product stock levels into a SQL database — from Shopify or WooCommerce via Airbyte or Fivetran, or from Square exports — and add a daily snapshot job for inventory. Then chart annualized turnover by quarter, days of inventory by category, and a slow-mover list ranked by days of stock.