Generated: 2026-04-10 | Stack: PostgreSQL + Metabase (no dbt) | Scope: full platform | Role: analyst
Day-1 orientation: This KB covers all 5 source tables, the 7 core business metrics, known data bugs, and safe SQL patterns. Read sections 5 and 7 before writing any query — they will save you from 3 common mistakes that cost analysts hours the first week.
Demo Retail is an e-commerce store selling consumer goods (Electronics, Clothing, Home, Sports, Books) across 3 regions: EU, US, and APAC. The business has ~500K lifetime orders, ~80K active customers, and 3 years of transaction history. Revenue reporting is in EUR — all amounts are FX-converted at load time, including US and APAC orders.
The data team is 2 analysts working from a shared operational PostgreSQL database, with Metabase connected for nightly-refresh dashboards. There is no dbt, no transformation layer, and no formal metric catalog yet — installing dbt and defining canonical metrics are the two highest-priority improvements (see the discovery report).
Key open business questions driving current analysis:
| Layer | Technology | Notes |
|---|---|---|
| Warehouse | PostgreSQL | Operational DB. public schema. 5 core tables. |
| Transformation | None (pre-dbt) | Queries run directly against source tables. dbt install is P0. |
| Metrics | Informal (schema_notes.md) |
No YAML catalog. Revenue calculated differently by each analyst — highest risk. |
| BI | Metabase | Nightly dashboard refresh. Multiple saved questions use SELECT * — fragile. |
| Access | Shared login | 2 analysts share 1 DB credential. No per-analyst audit trail. |
No live warehouse connection in this session — generated from static file analysis.
public schema)| Table | Grain | Approx. rows | Primary key | Notes |
|---|---|---|---|---|
orders |
1 row per order | ~500K | id |
~14K new orders/month |
customers |
1 row per registered customer | ~80K | id |
~12K have NULL plan_tier |
products |
1 row per SKU | ~3,200 | id |
Includes discontinued SKUs |
order_items |
1 row per line item per order | ~1.5–2M (est.) | id |
Critical: discount_pct is a decimal |
returns |
1 row per returned item | Unknown | id |
Jan 2025 has duplicate rows — always filter |
orders| Column | Type | Description |
|---|---|---|
id |
integer | PK. Auto-increment. |
customer_id |
integer | FK → customers.id |
status |
varchar | pending | processing | shipped | delivered | cancelled | returned |
total_amount |
numeric(10,2) | Pre-computed order total in EUR. Do not use for revenue — use order_items instead (see §5). |
currency |
varchar(3) | Always 'EUR' — misleading column, amounts are pre-converted. Do not filter on it. |
created_at |
timestamp | Order placement time. UTC timezone. |
shipped_at |
timestamp | NULL for ~8% of delivered orders — legacy import gap. |
delivered_at |
timestamp | NULL until delivered. |
customers| Column | Type | Description |
|---|---|---|
id |
integer | PK |
email |
varchar | Unique. PII — do not include in query results shared externally. |
country |
varchar(2) | ISO-2 code. Top 5: DE, FR, US, PL, GB |
city |
varchar | Free text. Not normalized. |
signup_date |
date | NULL for customers migrated before 2023-01-01 (~12K rows) |
plan_tier |
varchar | free | standard | premium. NULL for pre-2023 customers. |
last_order_date |
date | Denormalized. Updated by nightly job. May lag 1 day. |
products| Column | Type | Description |
|---|---|---|
id |
integer | PK |
name |
varchar | Free text. |
category |
varchar | Electronics | Clothing | Home | Sports | Books |
subcategory |
varchar | ~40 values. Not normalized. |
price |
numeric(10,2) | Current selling price — historical prices not tracked. |
cost |
numeric(10,2) | COGS. Sensitive — do not expose in client-facing or shared reports. |
is_active |
boolean | false = discontinued. Still appears in order/return history. |
order_items| Column | Type | Description |
|---|---|---|
id |
integer | PK |
order_id |
integer | FK → orders.id |
product_id |
integer | FK → products.id |
quantity |
integer | Always ≥ 1 |
unit_price |
numeric(10,2) | Price at purchase time. May differ from products.price (current). |
discount_pct |
numeric(5,4) | Decimal fraction, NOT percentage. 0.15 = 15% discount. |
Revenue formula: unit_price * quantity * (1 - discount_pct)
returns| Column | Type | Description |
|---|---|---|
id |
integer | PK |
order_id |
integer | FK → orders.id |
order_item_id |
integer | FK → order_items.id |
reason |
varchar | defective | wrong_item | not_as_described | changed_mind | damaged_in_transit |
refund_amount |
numeric(10,2) | Actual refund. May differ from unit_price (partial refunds). |
created_at |
timestamp | Return requested. |
processed_at |
timestamp | Refund issued. NULL if pending. |
Known bug: Duplicate rows for Jan 2025 return events. Fixed in pipeline Feb 2025, but duplicates remain in the table. See §7 for the safe deduplication filter.
No formal YAML catalog exists yet. These definitions come from schema_notes.md and represent
the canonical formulas — both analysts should use these until the semantic layer is built.
| Metric | Formula | Key exclusions |
|---|---|---|
| Gross Revenue | SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct)) |
status IN ('shipped','delivered') only |
| Net Revenue | Gross Revenue − SUM(returns.refund_amount) |
join on order_item_id |
| AOV | Gross Revenue / COUNT(DISTINCT order_id) |
same status filter |
| Return Rate | COUNT(DISTINCT returns.order_item_id) / NULLIF(COUNT(DISTINCT order_items.id), 0) |
exclude Jan 2025 duplicates |
| Conversion Rate | No reliable session data in DB — use as proxy: completed orders / signups | Very rough proxy only |
| Churn | Customers with last_order_date < CURRENT_DATE - 90 / total active |
"Active" = ordered in last 12 months |
| Customer LTV | SUM(revenue) per customer since signup |
Exclude NULL signup_date rows |
customers (grain: 1 row per customer, ~80K rows)
├── id (PK)
├── email (PII)
├── country (ISO-2), city
├── signup_date ← NULL for ~12K pre-2023 customers
├── plan_tier ← NULL for ~12K pre-2023 customers
└── last_order_date ← denormalized, 1-day lag
orders (grain: 1 row per order, ~500K rows)
├── id (PK)
├── customer_id → customers.id
├── status (pending|processing|shipped|delivered|cancelled|returned)
├── total_amount ← pre-computed, do not use for analytics revenue
├── currency ← always EUR, ignore for filtering
├── created_at (UTC)
├── shipped_at ← NULL for ~8% of delivered orders
└── delivered_at
order_items (grain: 1 row per line item, ~1.5–2M rows est.)
├── id (PK)
├── order_id → orders.id
├── product_id → products.id
├── quantity
├── unit_price ← historical price (not products.price)
└── discount_pct ← DECIMAL (0.15 = 15%), NOT percentage
returns (grain: 1 row per returned item)
├── id (PK)
├── order_id → orders.id
├── order_item_id → order_items.id
├── reason
├── refund_amount
├── created_at ← DUPLICATES in Jan 2025, always filter
└── processed_at
products (grain: 1 row per SKU, ~3,200 rows)
├── id (PK)
├── name, category, subcategory
├── price ← CURRENT price only, historical not stored
├── cost ← COGS, SENSITIVE
└── is_active ← false = discontinued, still in order history
orders → order_items for revenue — orders.total_amount is a denormalized
field and may not match the sum of line items (rounding, historical corrections).status IN ('shipped', 'delivered') should count as revenue.
Exclude pending, processing, cancelled, returned.discount_pct is a decimal. The correct multiplier is (1 - discount_pct). Multiplying by
(1 - discount_pct * 100) is 100x wrong — produces negative revenue for any discounted item.SUM(returns.refund_amount).
Refund amount may be less than unit_price (partial refunds are real and common).COALESCE(plan_tier, 'unknown') in GROUP BY. Bare GROUP BY plan_tier silently excludes
this cohort — churn and LTV analyses are materially understated without it.signup_date is NULL for the same pre-2023 cohort. Exclude WHERE signup_date IS NOT NULL
for acquisition cohort analysis. Do not exclude for total customer counts.last_order_date lags by 1 day. Acceptable for 90-day churn windows; do not use for
"ordered today" checks. The nightly update job runs at ~02:00 UTC.email is PII. Do not include in query results shared externally or pasted into Slack.
Use id for joins and country/plan_tier for segmentation.returns must either:
WHERE created_at NOT BETWEEN '2025-01-01' AND '2025-01-31' (excludes the month), ORSELECT MIN(id), order_item_id FROM returns GROUP BY order_item_id, created_at::date
The safest approach for return rate calculations is the filter — simpler and correct for most analyses.status = 'returned' on orders is order-level. It means the entire order was marked returned.
For item-level return analysis, use the returns table directly joined on order_item_id.processed_at is NULL for pending refunds. Filter WHERE processed_at IS NOT NULL when
measuring completed refund cycles.products.price is current price only. For revenue calculations, always use
order_items.unit_price (price at time of purchase). Joining products.price for revenue
is a common mistake that introduces price-drift errors.is_active = false) still appear in historical orders/returns. Do not
filter WHERE is_active = true when analyzing historical data — you'll lose the history.cost (COGS) is sensitive. Do not include in any report shared with non-finance stakeholders.completed orders / total signups is directionally useful but
noisy — do not report it as a hard metric without the caveat.All queries are written for PostgreSQL.
-- Canonical gross revenue — the safe version both analysts should use
SELECT
DATE_TRUNC('month', o.created_at) AS period,
SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct)) AS gross_revenue,
COUNT(DISTINCT o.id) AS order_count,
SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct))
/ NULLIF(COUNT(DISTINCT o.id), 0) AS aov
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status IN ('shipped', 'delivered')
GROUP BY 1
ORDER BY 1;
-- Gross revenue minus actual refunds
SELECT
DATE_TRUNC('month', o.created_at) AS period,
SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct)) AS gross_revenue,
COALESCE(SUM(r.refund_amount), 0) AS total_refunds,
SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct))
- COALESCE(SUM(r.refund_amount), 0) AS net_revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN returns r ON r.order_item_id = oi.id
AND r.created_at NOT BETWEEN '2025-01-01' AND '2025-01-31' -- exclude Jan 2025 bug
WHERE o.status IN ('shipped', 'delivered')
GROUP BY 1
ORDER BY 1;
-- Item-level return rate per category (exclude Jan 2025 duplicates)
SELECT
p.category,
COUNT(DISTINCT r.order_item_id)::FLOAT
/ NULLIF(COUNT(DISTINCT oi.id), 0) AS return_rate,
COUNT(DISTINCT oi.id) AS items_sold,
COUNT(DISTINCT r.order_item_id) AS items_returned
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
LEFT JOIN returns r ON r.order_item_id = oi.id
AND r.created_at NOT BETWEEN '2025-01-01' AND '2025-01-31'
WHERE o.status = 'delivered'
GROUP BY 1
ORDER BY 2 DESC;
-- Always COALESCE plan_tier — bare GROUP BY silently excludes ~12K customers
SELECT
COALESCE(plan_tier, 'unknown') AS tier,
COUNT(*) AS total_customers,
COUNT(CASE WHEN last_order_date >= CURRENT_DATE - INTERVAL '90 days'
THEN 1 END) AS active_90d
FROM customers
GROUP BY 1
ORDER BY 2 DESC;
-- Segment AOV by plan tier — reveals premium vs standard difference
SELECT
COALESCE(c.plan_tier, 'unknown') AS tier,
DATE_TRUNC('month', o.created_at) AS period,
SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct))
/ NULLIF(COUNT(DISTINCT o.id), 0) AS aov,
COUNT(DISTINCT o.id) AS orders
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN customers c ON c.id = o.customer_id
WHERE o.status IN ('shipped', 'delivered')
AND o.created_at >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY 1, 2
ORDER BY 2, 1;
-- What drives returns? (safe: excludes Jan 2025 duplicates)
SELECT
reason,
COUNT(*) AS return_count,
SUM(refund_amount) AS total_refunded,
COUNT(*)::FLOAT / SUM(COUNT(*)) OVER () AS share_of_returns
FROM returns
WHERE created_at NOT BETWEEN '2025-01-01' AND '2025-01-31'
AND processed_at IS NOT NULL -- completed refunds only
GROUP BY 1
ORDER BY 2 DESC;
-- Customers who haven't ordered in 90 days (among those active in last 12 months)
SELECT
COALESCE(plan_tier, 'unknown') AS tier,
COUNT(*) AS active_12m,
COUNT(CASE WHEN last_order_date < CURRENT_DATE - INTERVAL '90 days'
THEN 1 END) AS churned_90d,
COUNT(CASE WHEN last_order_date < CURRENT_DATE - INTERVAL '90 days'
THEN 1 END)::FLOAT
/ NULLIF(COUNT(*), 0) AS churn_rate
FROM customers
WHERE last_order_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY 1
ORDER BY 4 DESC;
discount_pct is a decimal, not a percentage (order_items.discount_pct): The column
stores 0.15 for a 15% discount. Writing (1 - discount_pct * 100) produces negative
unit prices for any discounted item. Always (1 - discount_pct).
Jan 2025 returns are duplicated (returns.created_at): A data pipeline bug created
duplicate rows for all return events in January 2025. The bug was fixed in February 2025
but the rows remain in the table. Any return count or return rate query must filter
created_at NOT BETWEEN '2025-01-01' AND '2025-01-31' or deduplicate on
(order_item_id, created_at::date).
orders.total_amount ≠ SUM(order_items) revenue (orders.total_amount): Use
order_items.unit_price * quantity * (1 - discount_pct) for all revenue calculations.
total_amount is a legacy column and may not match line-item math.
Cancelled/pending orders inflate revenue (orders.status): Always filter
WHERE status IN ('shipped', 'delivered'). Including pending or cancelled status
inflates revenue by orders that never completed.
NULL plan_tier excludes 15% of customers (customers.plan_tier): Approximately 12K
customers migrated before 2023 have no plan_tier. GROUP BY plan_tier silently excludes
them. Always COALESCE(plan_tier, 'unknown').
products.price is current, not historical (products.price): The price column reflects
today's price. For revenue analysis always use order_items.unit_price (recorded at purchase).
Using products.price for historical revenue will be wrong for any repriced SKU.
currency column is always 'EUR' (orders.currency): Despite selling in US/APAC markets,
all amounts are FX-converted at load time and stored in EUR. Do not use currency as a
filter or segment — it will always return only one value.
shipped_at is NULL for 8% of delivered orders (orders.shipped_at): Legacy import gap.
If you're calculating fulfilment time as shipped_at - created_at, you'll silently exclude
8% of delivered orders. Use delivered_at - created_at as a more reliable proxy.
email is PII (customers.email): Do not include in any result shared externally,
pasted into Slack, or exported to CSV. Use customers.id for joins.
Discontinued products in history (products.is_active = false): Filtering
WHERE is_active = true will exclude historical orders for discontinued SKUs. Only apply
this filter when querying the current active catalog (e.g. for pricing reports).
| Table | What it is | Key columns |
|---|---|---|
orders |
One row per order | id, customer_id, status, created_at |
order_items |
One row per line item | order_id, unit_price, quantity, discount_pct |
customers |
One row per customer | id, plan_tier, country, last_order_date |
products |
Product catalog | id, category, is_active (never use price for history) |
returns |
One row per returned item | order_item_id, refund_amount, reason, created_at |
| Metric | What it measures | Key table | Gotcha |
|---|---|---|---|
| Gross Revenue | Revenue from completed orders | order_items + orders |
Status filter + decimal discount |
| Net Revenue | Revenue after refunds | + returns |
Exclude Jan 2025 duplicates |
| AOV | Average order value | order_items + orders |
Segment by plan_tier with COALESCE |
| Return Rate | % of items returned | returns / order_items |
NULLIF denominator, Jan 2025 filter |
| Churn Rate | % of customers lapsed 90d | customers |
COALESCE plan_tier |
# No dbt — check when schema_notes.md was last updated:
git log --follow -p clients/demo-retail/schema_notes.md | head -20
# Full regeneration (static analysis):
/onboarding demo-retail
# Add new tables or refresh after schema changes:
/onboarding demo-retail --update
# With warehouse data (needs .env + API key):
source .venv/bin/activate
python -m agents.onboarding --client demo-retail
Add notes to ## Analyst Notes below. They are preserved on every regeneration.
Format: - [YYYY-MM-DD] Your note here
Metabase API not queried in this session — static analysis only.
Known dashboard patterns from profile.yaml:
SELECT * → fragile, break on column additions| Dashboard area | Status | Known issues |
|---|---|---|
| Revenue tracking | In use | May use orders.total_amount instead of order_items formula |
| Customer segmentation | In use | Likely missing COALESCE(plan_tier, 'unknown') |
| Returns analysis | In use | Likely does not filter Jan 2025 duplicates |
| Product category | In use | SELECT * patterns confirmed |
Populate with actual workbook names after Metabase access is granted.
Once dbt is installed (P0 action from discovery report), the following will be added to this KB:
stg_orders, stg_customers, etc. with type-cast columns)discount_pct renamed to discount_rate in staging layerclients/demo-retail/semantic_layer/metrics/Run /onboarding demo-retail --update after dbt is configured to regenerate this KB with model inventory.
Generated by Claude Code (Mode B — static file analysis)
Discovery report: clients/demo-retail/reports/2026-04-10_discovery.md (Level 1/5 — Raw)
Add corrections and discoveries below. Preserved on every regeneration.