Shipping in WooCommerce is built from zones. A zone is a geographic area with methods attached, and a customer's address matches exactly one; the first zone in the list that contains it.
That ordering rule is what makes shipping configuration behave predictably or mysteriously, and it is worth understanding before adding anything.
Zones match in order, and stop at the first
WooCommerce checks zones top to bottom and uses the first match. It does not combine them.
So specific zones must sit above general ones. A zone for your own country placed below a zone covering all of Europe never matches for domestic customers, because Europe caught them first.
Almost every "the wrong shipping cost appears" report is this.
Build it in the right order
- Your own country or region first, with your normal rates.
- Neighbouring regions you ship to regularly.
- Broader international zones.
- Everywhere else last, or nothing, which means you do not ship there.
The zone matching everywhere is the safety net. Without one, a customer outside your defined zones reaches checkout with no shipping method available and cannot complete the order, and they will not contact you to say so.
The three methods
Flat rate; a fixed amount, optionally per item or per class. Predictable for you and for the customer, and right for most small stores.
Free shipping: optionally above a minimum order value, which is a genuinely effective way to raise average order size.
Local pickup; no shipping, customer collects.
Carrier-calculated rates need an extension. They are accurate and they add a live API call to checkout, which is the page you cannot cache and where every extra dependency is a possible failure.
Shipping classes for awkward items
When some products cost much more to ship (bulky, heavy, fragile) put them in a shipping class and set a different rate for that class within each zone.
This is the mechanism for "everything is £5 except the large items". Without it you either overcharge everyone or lose money on the awkward items.
Test with real addresses
Configuration that looks right frequently is not, because of zone ordering.
Add products to the cart and check the shipping cost for: your own country, a neighbouring one, a distant one, and somewhere you did not think about. The last is what finds the missing catch-all zone.
Test with both a cheap item and a bulky one if you use shipping classes, and check the free shipping threshold triggers at the value you intended rather than one cent either side of it.
Free shipping thresholds and tax
Decide whether the minimum applies before or after tax, and configure it to match what you say on the site. A threshold that behaves differently from the banner advertising it generates support messages and abandoned carts.
What to check when shipping goes wrong
No shipping method available at checkout. The address matches no zone. Add a catch-all.
The wrong cost appears. Zone order; a broader zone is above a more specific one.
Free shipping not offered. The threshold is calculated on a different basis than you assumed, or a coupon reduced the cart below it.
Costs changed after a plugin update. Table rate and carrier extensions occasionally reset configuration. Check after updating anything that touches shipping.
Shipping configuration does not import from another platform and has to be rebuilt by hand. How to Migrate from Another Platform to WooCommerce walks through what else is in that category.
"No shipping options" at checkout
The most common shipping fault is not a wrong price. It is a checkout that offers nothing at all, and the customer leaves without telling you.
Four causes account for nearly all of it. The address falls outside every zone you defined. The zone matched has no method enabled. The method has a condition the cart does not meet, such as a minimum. Or the product has no weight and the rate needs one.
wp option get woocommerce_shipping_debug_mode wp wc shipping_zone list --user=1 --fields=id,zone_name,zone_order
Enabling the debug output shows which zone matched and why a method was withheld, on the checkout page itself. Turn it off afterwards, since it is visible to customers.
A zone covering the rest of the world, placed last with one flat method, converts this failure into a price you can at least discuss. Checkout optimisation explains the wider abandonment picture.
Rates that need data the products do not have
Weight based and dimension based rates fail quietly when the catalogue is incomplete, and a store that imported its products frequently has gaps.
wp db query "SELECT COUNT(*) AS missing FROM wp_postmeta pm JOIN wp_posts p ON p.ID = pm.post_id WHERE p.post_type='product' AND pm.meta_key='_weight' AND (pm.meta_value='' OR pm.meta_value IS NULL);"
Products with no weight are treated as weighing nothing, so a cart of them falls into the cheapest band or matches no band at all.
Fix the data rather than the rule. A default weight applied to everything is worse than a gap, because it produces confident wrong prices instead of an obvious failure. Adding and managing products goes into where the field lives.
Mixed carts and pickup
A cart holding one downloadable product and one physical product is where assumptions break.
Virtual and downloadable items carry no shipping requirement, so a cart of only those skips shipping entirely and any rule expecting a method never runs. Add one physical item and the whole cart needs shipping again, including the customer's address.
Local pickup deserves its own zone rather than being an option everywhere, since a pickup offered to a customer three countries away is an order you will have to cancel. Restrict it by postcode and say where the collection point is in the method title, where the customer actually reads it. Selling digital and downloadable products covers the other half of a mixed cart.
Live carrier rates fail differently
A rate fetched from a carrier at checkout introduces a dependency on somebody else's service at the moment a customer is paying.
Two things follow. The request adds its own delay to the checkout, and a slow carrier is felt as a slow store. And when the carrier is unavailable, the customer sees no shipping options rather than an error, which is the failure above wearing a different hat.
time curl -s -o /dev/null https://api.carrier.example/rates
So configure a fallback flat rate that applies when the live lookup returns nothing, and check that the plugin caches results for identical carts. An order completed at a slightly wrong price is recoverable. An abandoned checkout is not. Handling failed and pending payments deals with the same problem on the payment side.