E-commerce RTO: find where COD orders come back and what it costs
In this article (6 sections)
Related programme: the Data Science course (6 months; ₹1,30,000 (inclusive of GST) · EMI from ₹5,000/month).
RTO rate is the share of shipped orders that come back to origin undelivered, and it must be measured on a mature cohort: orders shipped long enough ago that every one of them has either been delivered or returned. Then estimate a rate per pincode shrunk toward the overall average so that tiny pincodes do not trigger policy, multiply by the cost of a failed shipment, and set cash-on-delivery rules on that expected cost rather than on the raw percentage.
The order book
The data is synthetic: 6,000 orders shipped between 1 July and 27 September 2026 to 40 pincodes, 60% on cash on delivery. Each pincode has a hidden true COD risk, and prepaid orders carry a quarter of it. Delivered orders resolve in 2–7 days; RTOs take 8–21 days because the courier attempts delivery several times before turning back.
import numpy as np
import pandas as pd
# Synthetic D2C order book, cut off on 27 Sep 2026 (not real merchant data)
rng = np.random.default_rng(7)
n = 6000
pins = [f"PIN{i:02d}" for i in range(40)]
pin_risk = dict(zip(pins, rng.beta(2, 12, len(pins)))) # hidden true COD RTO risk
pin_weight = rng.dirichlet(np.full(len(pins), 0.6)) # a few pincodes dominate volume
orders = pd.DataFrame({
"order_id": np.arange(1, n + 1),
"ship_date": pd.Timestamp("2026-07-01") + pd.to_timedelta(rng.integers(0, 88, n), unit="D"),
"pincode": rng.choice(pins, n, p=pin_weight),
"payment": rng.choice(["COD", "Prepaid"], n, p=[0.6, 0.4]),
"order_value": rng.integers(499, 2500, n),
})
p_rto = orders["pincode"].map(pin_risk) * np.where(orders["payment"] == "COD", 1.0, 0.25)
is_rto = rng.random(n) < p_rto
days_to_outcome = np.where(is_rto, rng.integers(8, 22, n), rng.integers(2, 8, n))
outcome_date = orders["ship_date"] + pd.to_timedelta(days_to_outcome, unit="D")
cutoff = pd.Timestamp("2026-09-27")
orders["status"] = np.where(outcome_date > cutoff, "in_transit",
np.where(is_rto, "rto", "delivered"))
print(orders["status"].value_counts().to_string())That output is the first warning. 298 orders are still in transit on the cutoff date, and they are disproportionately the ones heading for RTO.
Three RTO rates, one book
def rto_rate(df):
return (df["status"] == "rto").sum() / len(df) * 100
mature = orders[orders["ship_date"] <= cutoff - pd.Timedelta(days=21)]
views = pd.DataFrame({
"all shipped, in-transit as not RTO": orders.groupby("payment").apply(rto_rate, include_groups=False),
"closed orders only": orders[orders["status"] != "in_transit"].groupby("payment").apply(rto_rate, include_groups=False),
"mature cohort (shipped 21+ days ago)": mature.groupby("payment").apply(rto_rate, include_groups=False),
}).round(1)
print(views.T.to_string())
print("mature orders:", len(mature), "still in transit:", (mature["status"] == "in_transit").sum())| Method | COD RTO % | Prepaid RTO % |
|---|---|---|
| All shipped, in-transit counted as not RTO | 10.4 | 2.7 |
| Closed orders only | 11.0 | 2.8 |
| Mature cohort, shipped 21+ days before cutoff | 12.3 | 3.1 |
Only the mature cohort is unbiased here, and it contains 4,648 orders with none still in transit. The other two methods are the ones most weekly dashboards use. Dropping open orders does not fix the bias, because a fast delivery closes in under a week while a return takes up to three, so the closed set is tilted toward deliveries. Choose the maturity window from your own data: the 99th percentile of days from ship to final status, per courier.
What one RTO costs
A failed COD order is not a refund; no money was collected. The loss is the logistics spent on nothing. In this example, with hypothetical contract rates:
| Cost line | ₹ per RTO |
|---|---|
| Forward shipping | 70 |
| Return shipping | 70 |
| Receiving, inspection and repacking | 40 |
| Total | 180 |
The mature cohort has 2,715 COD orders and 334 RTOs, so ₹60,120 was spent on shipments that earned nothing. Add damaged returns that cannot be resold, and inventory locked in transit for three weeks during a sale, if you can measure them.
Pincode rates you can act on
Ranking pincodes by raw RTO rate promotes whichever pincode had three orders and one bad day. Shrink each pincode's rate toward the overall COD rate, with the prior counting as 50 pseudo-orders:
# Hypothetical per-order costs in Rs: replace with your courier contract
FORWARD, RETURN, HANDLING = 70, 70, 40
RTO_COST = FORWARD + RETURN + HANDLING
cod = mature[mature["payment"] == "COD"]
prior = (cod["status"] == "rto").mean()
K = 50 # prior worth 50 pseudo-orders
by_pin = cod.groupby("pincode").agg(orders=("order_id", "count"),
rtos=("status", lambda s: (s == "rto").sum()))
by_pin["raw_pct"] = by_pin["rtos"] / by_pin["orders"] * 100
by_pin["shrunk_pct"] = (by_pin["rtos"] + K * prior) / (by_pin["orders"] + K) * 100
by_pin["exp_rto_cost"] = by_pin["shrunk_pct"] / 100 * RTO_COST
by_pin["policy"] = pd.cut(by_pin["exp_rto_cost"], [0, 20, 35, float("inf")],
labels=["COD open", "COD after confirmation", "Prepaid only"])
print(f"prior COD RTO rate: {prior:.1%}, RTO cost Rs {RTO_COST}")
print(by_pin.sort_values("raw_pct", ascending=False).head(6).round(1).to_string())
print(by_pin["policy"].value_counts().sort_index().to_string())
share = by_pin.groupby("policy", observed=True)["orders"].sum() / by_pin["orders"].sum() * 100
print(share.round(1).to_string())
total_rto_cost = cod["status"].eq("rto").sum() * RTO_COST
print("mature COD orders:", len(cod), "RTOs:", cod["status"].eq("rto").sum(), "cost Rs", total_rto_cost)
# the generator knows each pincode's true COD risk, so we can score both estimates
truth = by_pin.index.map(pin_risk).to_numpy() * 100
print("mean abs error, raw:", round(np.mean(np.abs(by_pin["raw_pct"] - truth)), 2),
"shrunk:", round(np.mean(np.abs(by_pin["shrunk_pct"] - truth)), 2))The six pincodes with the highest raw rates:
| Pincode | COD orders | RTOs | Raw % | Shrunk % | Expected RTO cost (₹) | Policy |
|---|---|---|---|---|---|---|
| PIN33 | 17 | 7 | 41.2 | 19.6 | 35.3 | Prepaid only |
| PIN23 | 5 | 2 | 40.0 | 14.8 | 26.7 | COD after confirmation |
| PIN25 | 37 | 14 | 37.8 | 23.2 | 41.7 | Prepaid only |
| PIN35 | 3 | 1 | 33.3 | 13.5 | 24.3 | COD after confirmation |
| PIN16 | 130 | 40 | 30.8 | 25.6 | 46.2 | Prepaid only |
| PIN19 | 129 | 39 | 30.2 | 25.2 | 45.4 | Prepaid only |
PIN35's 33.3% rests on three orders and shrinks to 13.5%, close to the average. PIN16 and PIN19 keep rates above 25% because 130 orders of evidence outweigh the prior. Because the generator knows each pincode's true risk, the estimates can be scored: mean absolute error is 5.22 points for raw rates and 3.19 for shrunk rates. The prior strength of 50 is a tuning choice; test a few values against later months of your own data.
COD policy by expected cost
The bands below are illustrative and should come from your margin: a ₹35 expected loss matters on a ₹499 order and barely registers on a ₹2,400 one.
| Expected RTO cost per COD order | Rule | Pincodes | Share of COD orders |
|---|---|---|---|
| up to ₹20 | COD open | 13 | 53.2% |
| ₹20 to ₹35 | COD after an order-confirmation call or message | 19 | 33.4% |
| above ₹35 | Prepaid only | 5 | 13.4% |
Before switching a pincode to prepaid-only, measure how many of its customers actually complete a prepaid checkout. Some of the COD demand disappears instead of converting, and online payments have their own failure rate; see UPI failure rates for how to measure it honestly. A hold-out of a few pincodes kept on the old rule tells you whether the policy reduced RTO cost or just reduced orders.
Where RTO analysis misleads
- Pincode is a proxy. Address quality, first-time versus repeat buyer and order value often explain more than geography. Model them together once you have the volume; pincode rules are the first step, not the model.
- Courier mix. If one courier serves the risky pincodes, you may be measuring the courier's attempt discipline, not the customer.
- Fake delivery-failure reasons. "Customer unavailable" recorded without a real attempt inflates RTO. Call a sample of RTO customers before blaming them.
- Sale periods. Impulse orders placed during a sale return at higher rates. Compare like weeks with like weeks.
The operations head needs two numbers per week, both on mature cohorts: COD RTO rate and total RTO cost in rupees, with the pincode policy list attached. Censoring, shrinkage and expected-cost decisions are the statistical core of the Data Science programme, taught live online with optional in-person labs at our Noida centre.
Frequently asked questions
What is RTO in e-commerce?
RTO (return to origin) is a shipment that fails delivery and travels back to the seller's warehouse, typically because a cash-on-delivery customer refuses the parcel, is unreachable or gave a bad address. It differs from a customer return, which happens after a successful delivery. The seller usually pays shipping both ways and collects nothing.
How do you calculate the RTO rate?
Divide RTO shipments by shipped orders, but only for orders shipped long enough ago that their outcome is known. Including recent shipments that are still in transit counts them as successes and understates the rate; excluding them from both sides overstates deliveries because deliveries resolve faster than returns.
Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.
This article is part of our Data Science programme — 6 months. From data foundations to machine learning, deep learning and deployment.
Explore Data Science