Learn how to turn raw transaction data into real business KPIs using pandas. This hands-on lesson walks through computing revenue, gross margin, conversion rate, average order value, and customer-level metrics — with the common pitfalls explained so you get the numbers right.

You've just been handed a CSV export from your company's order management system. It has 50,000 rows, columns like order_id, product, quantity, unit_price, cost, status, and customer_id. Your manager wants to know: What's our revenue this month? What's the gross margin by product category? What percentage of visitors converted to buyers? What's the average order value?
In Excel, you'd probably start building formulas in empty columns, create a PivotTable or two, and hope nothing breaks when next month's data arrives. In pandas, you build these metrics once as clean, repeatable Python code — and they run the same way every time, on any size dataset. This lesson teaches you exactly how to do that.
By the end of this lesson, you'll be able to take raw transaction data and compute the core KPIs (Key Performance Indicators — the quantitative measures businesses use to track performance) that appear in virtually every business analytics role: revenue, gross margin, conversion rate, average order value, and customer-level metrics. You'll understand not just the formulas, but how to structure your calculations so they're readable, auditable, and reusable.
What you'll learn:
groupbyYou should be comfortable loading a CSV into a DataFrame and doing basic column operations. If you haven't done that yet, start with Your First pandas DataFrame: Loading CSV and Excel Files and Exploring Data. You should also understand boolean filtering — covered in Selecting and Filtering Data in pandas: loc, iloc, and Boolean Masks — because most business metrics require filtering down to completed orders, active customers, or specific time windows before you calculate anything.
Rather than loading a file you don't have, let's build a realistic transaction dataset directly in Python. This also demonstrates a useful skill: constructing test data to verify your logic before applying it to production data.
import pandas as pd
import numpy as np
np.random.seed(42)
n = 500
products = ['Laptop', 'Monitor', 'Keyboard', 'Mouse', 'Webcam', 'Headset']
categories = {
'Laptop': 'Computers',
'Monitor': 'Computers',
'Keyboard': 'Peripherals',
'Mouse': 'Peripherals',
'Webcam': 'Peripherals',
'Headset': 'Audio'
}
unit_prices = {
'Laptop': 1200, 'Monitor': 450, 'Keyboard': 95,
'Mouse': 35, 'Webcam': 80, 'Headset': 120
}
cost_ratios = {
'Laptop': 0.72, 'Monitor': 0.65, 'Keyboard': 0.55,
'Mouse': 0.50, 'Webcam': 0.60, 'Headset': 0.58
}
statuses = ['completed', 'completed', 'completed', 'refunded', 'cancelled']
product_col = np.random.choice(products, n)
df = pd.DataFrame({
'order_id': range(1001, 1001 + n),
'customer_id': np.random.randint(2001, 2201, n),
'product': product_col,
'category': [categories[p] for p in product_col],
'quantity': np.random.randint(1, 5, n),
'unit_price': [unit_prices[p] for p in product_col],
'unit_cost': [unit_prices[p] * cost_ratios[p] for p in product_col],
'status': np.random.choice(statuses, n),
'order_date': pd.date_range('2024-01-01', periods=n, freq='14H')
})
print(df.head())
print(df.shape)
print(df['status'].value_counts())
Take a moment to understand what we've built: each row is a single line item from an order. We have a unit price (what the customer pays per item), a unit cost (what we paid to acquire or produce the item), a quantity, and a status indicating whether the order actually completed. These four fields are the raw ingredients for almost every business metric you'll compute.
Revenue is simple in concept — price times quantity — but in practice, you need to be precise about which orders to count. Refunded and cancelled orders should not contribute to revenue. This is where many quick analyses go wrong: they forget to filter.
Let's first compute line-item revenue for all rows, then filter to completed orders only.
# Step 1: Add a revenue column for every row
df['revenue'] = df['unit_price'] * df['quantity']
# Step 2: Filter to completed orders only
completed = df[df['status'] == 'completed'].copy()
print(f"Total rows: {len(df)}")
print(f"Completed orders: {len(completed)}")
print(f"Total revenue (completed only): ${completed['revenue'].sum():,.2f}")
Warning
Always filter before aggregating when your data has status fields. If you sum revenue across all rows including cancellations and refunds, you'll overstate performance — sometimes dramatically. Build the filter into your metric definition, not as an afterthought.
The .copy() call after filtering creates an independent DataFrame rather than a view of the original. This matters because we're about to add more columns, and pandas will raise a SettingWithCopyWarning if you try to assign to a slice.
Gross profit is revenue minus the cost of goods sold (COGS). Gross margin is that profit expressed as a percentage of revenue. These two metrics together tell you how efficiently a product generates profit — a product with high revenue but thin margins may be less valuable than a smaller product with rich margins.
# Gross profit at the line-item level
completed['gross_profit'] = (completed['unit_price'] - completed['unit_cost']) * completed['quantity']
# Gross margin percentage at the line-item level
completed['gross_margin_pct'] = completed['gross_profit'] / completed['revenue']
print(completed[['product', 'quantity', 'revenue', 'gross_profit', 'gross_margin_pct']].head(10))
Now aggregate up to the category level:
category_summary = completed.groupby('category').agg(
total_revenue=('revenue', 'sum'),
total_profit=('gross_profit', 'sum'),
order_count=('order_id', 'count')
).reset_index()
# Margin must be calculated AFTER aggregation, not by averaging the row-level percentages
category_summary['gross_margin_pct'] = (
category_summary['total_profit'] / category_summary['total_revenue']
)
print(category_summary.round(4))
Key insight
Never calculate margin percentage by averaging row-level margin percentages. That's a weighted averaging mistake. A product sold 1 time at 80% margin and 1000 times at 20% margin does not have a 50% average margin — it has something much closer to 20%. Always sum dollars first, then divide.
This is one of the most common errors in business metric calculations, and it's easy to make when you're thinking in terms of "average the percentage column." The correct approach is always: aggregate the numerator and denominator separately, then compute the ratio.
Average Order Value is the mean revenue per distinct order. It's a fundamental e-commerce metric that drives decisions about upselling, promotions, and customer acquisition cost thresholds.
The subtlety here is that our dataset has one row per line item, but a single order might contain multiple products. To calculate true AOV, we need to group by order_id first, sum revenue within each order, then average across orders.
# Revenue per order (in case one order has multiple line items)
order_totals = completed.groupby('order_id')['revenue'].sum().reset_index()
order_totals.columns = ['order_id', 'order_revenue']
aov = order_totals['order_revenue'].mean()
print(f"Average Order Value: ${aov:,.2f}")
print(f"Median Order Value: ${order_totals['order_revenue'].median():,.2f}")
Tip
Always report median alongside mean for financial metrics. AOV is easily skewed by a handful of very large orders, and the median gives you a better sense of the "typical" order. If mean is $450 but median is $120, you have a small number of huge orders distorting the picture.
You can also break AOV down by product category or time period:
# AOV by category
aov_by_category = completed.groupby('category').apply(
lambda x: x.groupby('order_id')['revenue'].sum().mean()
).reset_index()
aov_by_category.columns = ['category', 'avg_order_value']
print(aov_by_category.sort_values('avg_order_value', ascending=False))
Conversion rate measures what fraction of potential customers (or sessions, or leads) actually completed a purchase. In our dataset, we have orders with different statuses — completed, refunded, cancelled. We can use this to compute a rough order-level conversion rate.
# Conversion rate: completed orders / total orders attempted
status_counts = df.groupby('status')['order_id'].count()
print(status_counts)
total_orders = len(df)
completed_orders = len(df[df['status'] == 'completed'])
conversion_rate = completed_orders / total_orders
print(f"\nConversion Rate: {conversion_rate:.1%}")
In a real web analytics context, you'd have a funnel table that looks something like this:
# Simulating a conversion funnel
funnel = pd.DataFrame({
'stage': ['Site Visits', 'Product Views', 'Add to Cart', 'Checkout Started', 'Order Completed'],
'users': [50000, 22000, 8500, 3200, 1800]
})
# Stage-over-stage conversion
funnel['prev_users'] = funnel['users'].shift(1)
funnel['stage_conversion'] = funnel['users'] / funnel['prev_users']
# Overall conversion from top of funnel
funnel['overall_conversion'] = funnel['users'] / funnel['users'].iloc[0]
print(funnel[['stage', 'users', 'stage_conversion', 'overall_conversion']].to_string(index=False))
The shift(1) function moves values down by one row, so when you divide users by prev_users, you get the percentage of each prior stage that advanced. This funnel pattern — create a lagged column, divide — shows up constantly in business reporting. You can learn more about period-to-period comparisons like this in Calculating Month-over-Month and Year-over-Year Changes in pandas: pct_change, shift, and Period Comparisons for Business Reporting.
Many of the most important business metrics are customer-level, not order-level. Customer Lifetime Value (LTV), purchase frequency, and revenue concentration all require grouping by customer.
# Customer-level aggregation
customer_metrics = completed.groupby('customer_id').agg(
total_revenue=('revenue', 'sum'),
total_orders=('order_id', 'nunique'),
total_units=('quantity', 'sum'),
avg_order_revenue=('revenue', 'mean')
).reset_index()
# Purchase frequency: how many orders per customer on average?
avg_purchase_frequency = customer_metrics['total_orders'].mean()
print(f"Avg purchase frequency: {avg_purchase_frequency:.2f} orders per customer")
# Average revenue per customer
avg_revenue_per_customer = customer_metrics['total_revenue'].mean()
print(f"Avg revenue per customer: ${avg_revenue_per_customer:,.2f}")
# Revenue concentration: what % of revenue comes from top 20% of customers?
customer_metrics_sorted = customer_metrics.sort_values('total_revenue', ascending=False)
top_20_pct_count = int(len(customer_metrics_sorted) * 0.20)
top_20_revenue = customer_metrics_sorted.head(top_20_pct_count)['total_revenue'].sum()
total_revenue = customer_metrics_sorted['total_revenue'].sum()
print(f"Top 20% of customers generate {top_20_revenue / total_revenue:.1%} of revenue")
Note
The nunique() aggregation counts distinct values — use it when you want to count unique orders per customer rather than row count. If a customer has 3 line items on the same order, count() gives you 3, but nunique() on order_id gives you 1. This distinction matters enormously for frequency calculations.
Individual metrics are useful, but executives and stakeholders usually want a consolidated summary. Here's how to build one in pandas:
# Pull everything together into a clean summary dictionary
kpi_summary = {
'Total Revenue': f"${completed['revenue'].sum():,.2f}",
'Total Gross Profit': f"${completed['gross_profit'].sum():,.2f}",
'Overall Gross Margin': f"{completed['gross_profit'].sum() / completed['revenue'].sum():.1%}",
'Completed Orders': len(completed['order_id'].unique()),
'Average Order Value': f"${order_totals['order_revenue'].mean():,.2f}",
'Conversion Rate': f"{conversion_rate:.1%}",
'Unique Customers': completed['customer_id'].nunique(),
'Revenue per Customer': f"${completed['revenue'].sum() / completed['customer_id'].nunique():,.2f}"
}
kpi_df = pd.DataFrame.from_dict(kpi_summary, orient='index', columns=['Value'])
print(kpi_df)
This KPI table can be exported to Excel or dropped into a report. If you're building recurring reports, you can parameterize this by date range and run it automatically — a pattern covered in Automating Excel Reports with pandas and openpyxl: Formatted Workbooks Without Manual Work.
Tip
When building summary tables for non-technical stakeholders, format numbers as strings with proper commas and percent signs before displaying. It prevents the "why does it say 0.6234 instead of 62%?" conversation every time.
Business metrics are almost always tracked over time. Flat numbers without a time dimension don't tell you whether you're growing or declining.
# Extract month from order date
completed['month'] = completed['order_date'].dt.to_period('M')
# Monthly KPIs
monthly_summary = completed.groupby('month').agg(
revenue=('revenue', 'sum'),
gross_profit=('gross_profit', 'sum'),
orders=('order_id', 'nunique'),
customers=('customer_id', 'nunique')
).reset_index()
monthly_summary['gross_margin_pct'] = monthly_summary['gross_profit'] / monthly_summary['revenue']
monthly_summary['aov'] = monthly_summary['revenue'] / monthly_summary['orders']
# Month-over-month revenue growth
monthly_summary['revenue_mom_change'] = monthly_summary['revenue'].pct_change()
print(monthly_summary.to_string(index=False))
The .dt accessor unlocks date-based operations on datetime columns — extracting month, year, day of week, and more. For a deeper dive into working with dates, see Working with Dates and Time Series in pandas: Parsing, Resampling, and Rolling Windows.
Work through these tasks using the dataset built in this lesson:
Exercise 1 — Product-level metrics: Build a summary table grouped by product (not category) that shows: total revenue, total gross profit, gross margin %, number of orders, and average quantity per order. Sort by total revenue descending.
Exercise 2 — Top customers report: Find the top 10 customers by total revenue from completed orders. For each customer, show their total revenue, number of unique orders, and gross margin percentage across their purchases.
Exercise 3 — Monthly conversion trend: Using the full df (including all statuses), calculate the conversion rate (completed / total) by calendar month. Create a column showing whether that month's conversion rate was above or below the overall average.
Stretch goal: Calculate what percentage of total revenue comes from repeat customers (customers with more than one completed order) versus first-time buyers. This is a key metric for understanding customer loyalty and marketing efficiency.
Forgetting to filter out non-completed orders. This is mistake number one. Always define exactly which rows count toward your metric before computing it. Build a completed DataFrame at the start and use it consistently.
Averaging percentages instead of computing them from summed components. If you do df.groupby('category')['gross_margin_pct'].mean(), you'll get the wrong answer when group sizes differ. Always aggregate dollar amounts first, then compute the ratio.
Confusing order count with line item count. In a dataset where one order can have multiple rows (multiple products), count() and nunique() give very different results. For "number of orders," use nunique() on order_id. For total line items, use count(). Know which one your metric requires.
Not using .copy() after filtering. When you filter a DataFrame and then assign new columns, pandas may warn you about modifying a copy of a slice. Add .copy() after your filter: completed = df[df['status'] == 'completed'].copy().
Division by zero in margin and rate calculations. If any group has zero revenue (possible in edge cases with real data), dividing by it produces inf or NaN. Use df['revenue'].replace(0, np.nan) before dividing, or add a check: np.where(df['revenue'] > 0, df['profit'] / df['revenue'], np.nan). For more on building conditional columns safely, see Conditional Column Creation in pandas: Adding Calculated Fields with np.where, cut, and map.
Mixing row-level and aggregate calculations. Revenue per customer should come from a customer-level groupby, not from the raw transaction table. If you accidentally average revenue across all line items per customer, you're computing something meaningless. Mentally model what each row represents at each step.
You've built a complete business metrics pipeline from raw transaction data. Here's what you can now do:
groupby and nuniqueThe patterns here — filter first, compute row-level fields, aggregate, derive ratios from aggregated components — apply to virtually every business metric you'll ever build in pandas.
From here, a natural next step is understanding how your customer base behaves over time: which cohorts are retaining, which are churning, and what the lifetime value looks like by acquisition period. That's covered in Cohort Analysis in pandas: Calculating Retention, Churn, and Lifetime Value from Transaction Data.
If your metrics need to be pulled from a SQL database rather than a CSV, the approach integrates cleanly with Reading from SQL Databases into pandas with SQLAlchemy — you query the raw transactions from your database and then apply the same calculation patterns from this lesson.
And when you're ready to turn these metrics into a polished, recurring report that runs automatically, Building and Automating Recurring Reports with pandas: Scheduling Scripts to Run Without You covers the full automation workflow.
Python for Data Analysis