Data ScienceIndustry analytics playbooks

E-commerce RTO: find where COD orders come back and what it costs

PK
Pankit Kumar
Sr. Data Scientist at Parexel (a Goldman Sachs–backed company) · 27 September 2026 · 6 min read
In this article (6 sections)

Related programme: the Data Science course (6 months; ₹1,30,000 (inclusive of GST) · EMI from ₹5,000/month).

The short answer

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.

python
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

python
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())
MethodCOD RTO %Prepaid RTO %
All shipped, in-transit counted as not RTO10.42.7
Closed orders only11.02.8
Mature cohort, shipped 21+ days before cutoff12.33.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 shipping70
Return shipping70
Receiving, inspection and repacking40
Total180

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:

python
# 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:

PincodeCOD ordersRTOsRaw %Shrunk %Expected RTO cost (₹)Policy
PIN3317741.219.635.3Prepaid only
PIN235240.014.826.7COD after confirmation
PIN25371437.823.241.7Prepaid only
PIN353133.313.524.3COD after confirmation
PIN161304030.825.646.2Prepaid only
PIN191293930.225.245.4Prepaid 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 orderRulePincodesShare of COD orders
up to ₹20COD open1353.2%
₹20 to ₹35COD after an order-confirmation call or message1933.4%
above ₹35Prepaid only513.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.

PK
Pankit Kumar
Lead Instructor, NeuraPath Academy

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
Counselling is free · no obligation

Not sure which programme fits?

Tell us your background and we will map it to the right entry point — including saying so when a cheaper programme is the better fit. A counsellor replies within one working day.