When the checkout blocks the order with “There are no shipping options available”, WooCommerce has already built the cart, priced the items and asked every shipping method for a rate — and received nothing back. The WooCommerce no shipping options error is almost never a bug in the plugin itself. It is the predictable result of a zone that did not match the customer’s address, a method instance that was not available, or a rule that excluded every item in the package. This tutorial compares three ways to chase it down and then builds the one that gives a definitive answer instead of a guess.
You need WooCommerce 8.x or later, an admin account with the manage_woocommerce capability, and file access (SFTP, a file manager, or WP-CLI) to drop a small must-use plugin into wp-content/mu-plugins/. No live site changes are needed until you have read the diagnostic output.
What the “no shipping options available” message actually means in the shipping pipeline
Shipping in WooCommerce runs in four stages, and the error appears only at the end of them:
- Packages.
WC_Cart::get_shipping_packages()splits the cart into one or more packages, each with a destination array (country, state, postcode, city) and a contents array. - Zone matching.
WC_Shipping_Zones::get_zone_matching_package()walks the zones in their saved order and returns the first zone whose location rules match the destination. Matching stops there — it does not fall through to the next zone if that zone has no usable methods. - Method availability. Every enabled method instance in the matched zone is asked
is_available( $package ). Flat Rate checks its own class costs, Free Shipping checks its minimum amount or coupon requirement, and live-rate gateways check their API credentials. - Rates. Available methods return
WC_Shipping_Rateobjects. If the final array is empty, the Checkout block renders the message you are staring at.
That order matters, because it tells you where to look. A zone that matches but yields no rates looks identical to a zone that never matched — from the customer’s side, both produce the same sentence.
Three ways to find the cause, and when each one is the right tool
Auditing zones and method instances in the admin
Open WooCommerce → Settings → Shipping and read every zone, its regions, and the methods inside it. This is fast, needs no code, and is the correct first move on a store with one or two zones, no shipping classes and no live-rate extensions. It is also blind: it cannot tell you which zone a specific postcode matched, and it cannot see rules added by a plugin or a snippet.
Dumping matched zones and packages from a live cart
Add a small diagnostic that rebuilds the packages for the current cart and prints the matched zone, the methods inside it, their availability and the resulting rates. This takes ten minutes to set up and answers the question directly, including for carts with classes, weight rules and third-party methods. On any store with more than one zone, this is the approach worth implementing — and it is the one built in full below.
Forcing a fallback rate with a filter
Hooking woocommerce_package_rates and injecting a rate when the array is empty guarantees the customer can always pay. It is a legitimate business rule for “we will quote delivery after the order”, but it is not a diagnosis. Used as a first response it hides the real fault, and you will keep shipping free or under-priced orders for months. Keep it for last, and only when the fallback is a deliberate policy.
Zone coverage fails silently when no region matches the customer address
Zones are evaluated top to bottom, and the first match wins. Three coverage mistakes account for most cases:
- No catch-all zone. If every zone lists specific countries and the customer is in a country that appears nowhere, no zone matches at all. Add a “Rest of the world” zone with a Zone region of Everywhere and give it at least one method.
- A matching zone with no methods. The customer’s country is listed in a zone that contains only a disabled method. Matching stops there, so the perfectly good zone below it is never consulted.
- Over-specific rules. Postcode rules such as
SW*or ranges like90210...90220only match when the postcode is present and formatted as expected. A zone that requires a postcode will not match a cart where only the country is known.
Also check WooCommerce → Settings → Shipping → Shipping options. If “Hide shipping costs until an address is entered” is on and the customer has not supplied a full address, the cart has nothing to price against — the Blocks checkout often surfaces that as the same error rather than a prompt.
To list every zone, its regions and its methods without clicking through the admin, use WP-CLI:
wp eval '
foreach ( WC_Shipping_Zones::get_zones() as $data ) {
$zone = WC_Shipping_Zones::get_zone( $data["zone_id"] );
printf( "#%d %s\n", $zone->get_id(), $zone->get_zone_name() );
foreach ( $zone->get_zone_locations() as $loc ) {
printf( " location: %s = %s\n", $loc->type, $loc->code );
}
foreach ( $zone->get_shipping_methods( true ) as $method ) {
printf( " method: %s (%s) enabled=%s\n", $method->get_title(), $method->id, $method->is_enabled() ? "yes" : "no" );
}
}
'
Reading the output in zone order shows you immediately whether a country is covered twice, covered by an empty zone, or not covered at all.
A method instance can be enabled and still return nothing
Inside a zone, each row is a separate instance with its own settings. The checkbox on the left only controls whether the instance is active; availability is decided by the instance’s own rules:
- Free Shipping with “Requires a minimum order amount” or “Requires a coupon” returns nothing when the cart does not qualify. If it is the only method in the zone, the checkout breaks.
- Flat Rate with a blank cost for the product’s shipping class and a blank “No shipping class cost” produces a zero-cost rate — still a rate, so no error, but a silent revenue leak worth spotting while you are in there.
- Live rates (carrier extensions, table-rate plugins) return nothing when the API rejects the request: missing weight, missing dimensions, an unserviceable postcode, or expired credentials. Check WooCommerce → Status → Logs for the extension’s own log before touching zones.
One more trap: rates are cached in the customer session under shipping_for_package_<key>. After you change a zone or a method, an existing session can keep serving the old, empty result. Empty the cart, or clear the session key, before you conclude the fix did not work.
Shipping classes and weight rules exclude whole carts without an error
Shipping classes are assigned per product and per variation, and a single mismatched item can change the outcome for the whole package. Two things to verify:
- Every product in the cart has a class that the matched method actually prices. A class created after the method was configured will have no cost row.
- Weight-based rules use the summed weight of the package, not the per-item weight. A table-rate rule of “0–1 kg” fails for a cart of three 0.4 kg items even though each item looks fine on its own product page.
This is exactly the class of problem the admin UI cannot show you, which is why the next step is a dump rather than more clicking.
Build the diagnostic dump that shows the matched zone, methods and rates
Create wp-content/mu-plugins/shipping-match-debugger.php. A must-use plugin loads automatically, cannot be deactivated by accident, and stays out of the plugins list. It only runs for logged-in users who can manage WooCommerce and who append ?ship_debug=1 to the cart URL, so customers never see the output.
<?php
/**
* Plugin Name: WPTH Shipping Match Debugger
* Description: Prints packages, matched zones, methods and rates for the current cart.
*/
defined( 'ABSPATH' ) || exit;
add_action( 'woocommerce_before_cart', 'wpth_dump_shipping_matches' );
function wpth_dump_shipping_matches() {
if ( ! isset( $_GET['ship_debug'] ) || ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
if ( ! WC()->cart || WC()->cart->is_empty() ) {
return;
}
WC()->cart->calculate_shipping();
$packages = WC()->shipping()->get_packages();
echo '<pre style="padding:1em;background:#111;color:#0f0;font-size:13px;white-space:pre-wrap">';
foreach ( $packages as $index => $package ) {
printf(
"Package %d: %d item(s) to %s / %s / %s\n",
$index,
count( $package['contents'] ),
$package['destination']['country'],
$package['destination']['state'],
$package['destination']['postcode']
);
$zone = WC_Shipping_Zones::get_zone_matching_package( $package );
printf( " Zone: #%d %s\n", $zone->get_id(), $zone->get_zone_name() );
foreach ( $zone->get_shipping_methods( true ) as $method ) {
printf(
" Method: %s [%s:%d] available=%s\n",
$method->get_title(),
$method->id,
$method->instance_id,
$method->is_available( $package ) ? 'yes' : 'no'
);
}
if ( empty( $package['rates'] ) ) {
echo " RATES: none - this package is what breaks the checkout\n";
} else {
foreach ( $package['rates'] as $rate ) {
printf( " Rate: %s = %s\n", $rate->get_label(), $rate->get_cost() );
}
}
foreach ( $package['contents'] as $item ) {
printf(
" Item: %s x%d | class=%s | weight=%s\n",
$item['data']->get_name(),
$item['quantity'],
$item['data']->get_shipping_class() ?: 'none',
wc_format_weight( $item['data']->get_weight() )
);
}
}
echo '</pre>';
}
Add the products to the cart as the customer would, then load /cart/?ship_debug=1 while logged in as an administrator. The block prints one section per package.
Here is the output on a test store running WooCommerce 11.2.2, with a single shipping zone for the United Kingdom (Flat rate £4.95, plus £20 for the Bulky shipping class). The cart holds two mugs and a bench. With a US shipping address, no zone matches, so there is nothing to price:
Package 0: 2 item(s) to US / CA / 94105
Zone: #0 Locations not covered by your other zones
RATES: none - this package is what breaks the checkout
Item: Enamel Mug x2 | class=none | weight=0.4 kg
Item: Garden Bench x1 | class=bulky | weight=18 kg
The same cart with a UK address matches the zone, the method is available, and the rate includes the Bulky class cost:
Package 0: 2 item(s) to GB / / SW1A 1AA
Zone: #1 United Kingdom
Method: Flat rate [flat_rate:1] available=yes
Rate: Flat rate = 24.95
Item: Enamel Mug x2 | class=none | weight=0.4 kg
Item: Garden Bench x1 | class=bulky | weight=18 kg
Reading the dump: the four lines that identify the failure
Work down each package in order and stop at the first line that looks wrong:
- Destination. If the country, state or postcode is empty or wrong, the problem is upstream of shipping — an address field, a default customer location setting, or a geolocation plugin.
- Zone. A zone ID of
0named “Locations not covered by your other zones” in the dump (the Shipping zones screen calls the same zone “Rest of the world”) means no configured zone matched and WooCommerce fell back to the default. That is a coverage gap, not a method problem. - Method availability. A method listed with
available=nois the culprit: its minimum amount, coupon requirement, class cost or API call rejected the package. A zone with no methods listed at all is the other common culprit. - Rates. If methods show
available=yesbut the rate list is empty, a filter or a third-party plugin is stripping the rates after calculation — search the codebase forwoocommerce_package_rates.
To catch that last case in the log rather than on screen, add a temporary logger. It writes only when WP_DEBUG is on, so it is safe to leave in a staging copy:
add_filter( 'woocommerce_package_rates', function ( $rates, $package ) {
if ( ! empty( $rates ) || ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return $rates;
}
$weight = 0;
foreach ( $package['contents'] as $item ) {
$weight += (float) $item['data']->get_weight() * $item['quantity'];
}
error_log( sprintf(
'[shipping] no rates for %s/%s/%s | items=%d | weight=%s',
$package['destination']['country'],
$package['destination']['state'],
$package['destination']['postcode'],
count( $package['contents'] ),
wc_format_weight( $weight )
) );
return $rates;
}, 10, 2 );
Fix the cause in the zone, not in the checkout template
Once the dump names the failing stage, the repair is usually a two-minute admin change:
- No zone matched: add a catch-all zone with the Everywhere region and at least one method, then drag it to the bottom of the zone list.
- Wrong zone matched: reorder the zones so the more specific one sits above the general one, and remove the country from the zone that should not have claimed it.
- Method unavailable: open the instance and relax the condition — lower the free-shipping threshold, remove the coupon requirement, or fill in the missing class cost.
- Live rates empty: fix the data the carrier needs (weight, dimensions, a valid postcode) or the credentials, and check the extension log again.
After any change, empty the cart and re-run the dump. If the rates appear but the checkout still complains, clear the shipping transient version with WC_Cache_Helper::get_transient_version( 'shipping', true ) and retest in a private window.
When a fallback rate filter is the correct answer
Some stores genuinely cannot price every destination — freight to remote islands, made-to-order items, or a trade catalogue quoted after the order. In that case, and only after the dump has confirmed the gap is intentional, inject a zero-cost “arranged later” rate so the order can complete:
add_filter( 'woocommerce_package_rates', function ( $rates, $package ) {
if ( ! empty( $rates ) ) {
return $rates;
}
$fallback = new WC_Shipping_Rate(
'wpth_fallback',
__( 'Delivery arranged after order', 'your-textdomain' ),
0,
array(),
'wpth_fallback',
0
);
return array( 'wpth_fallback' => $fallback );
}, 20, 2 );
Pair it with an order note or an admin email so the quote is never forgotten, and log every time it fires — a fallback that triggers on 30% of orders is a coverage problem wearing a disguise.
The natural follow-up is to stop discovering these gaps one customer at a time: wrap the same package-building logic in a WP-CLI command that takes a CSV of country/state/postcode combinations, replays each one through WC_Shipping_Zones::get_zone_matching_package(), and prints the zone and rate count. Run it after every zone change and you will see a newly orphaned region before a customer does.