Shopify shipping cost before checkout: where it hides, and three ways to show it
Shopify prices delivery from the address, so the rate first appears at checkout step two. What the abandonment data says, and three ways to show it sooner.
12 min read
Contents — 8 sections
- The rate depends on the address, so Shopify computes it at checkout
- The abandonment data says extra costs are the top fixable reason, and browsing is the top reason overall
- Check what your store reveals, and where, in five minutes
- The policy line is the cheapest fix, and the sentence above the button is the second
- A free-shipping bar is arithmetic on `cart.total_price`, and it does not need an app
- An estimator asks for the destination the checkout would ask for anyway
- Google can show the shipping cost before the visitor arrives
- Fix it in this order; the first step takes ten minutes
The rate depends on the address, so Shopify computes it at checkout
A shipping rate on Shopify is not a property of the product. It is the output of a lookup that needs a destination. In the admin, under Settings, Shipping and delivery, rates are grouped into zones — a zone being a group of countries that share the same rates, and a customer can only order if their destination falls inside one, as the DeliveryZone reference puts it. Inside a zone, each rate is a method definition: a name, a price, and the conditions that must pass for it to apply, per the DeliveryMethodDefinition reference. A condition compares one of two fields, the order's total price or its total weight, against a value — the DeliveryConditionField enum has exactly those two members. "Free above a threshold" is a method definition with a total-price condition; "flat rate below it" is a second one.
None of that can be evaluated until the store knows where the parcel is going. So checkout asks for the address first and prices the delivery second. Shopify's own description of its checkout page layouts is explicit: the three-page layout is the default, it separates the flow into information, shipping and payment, and buyers complete one page before moving to the next. The one-page layout puts all three on one screen, but the shipping method section still sits under the delivery address, because it is still computed from it. On the headless side the same rule holds — the Storefront API's Cart object exposes delivery groups "based on the buyer identity default delivery address preference or the default address of the logged-in customer", and the cost it reports before that is documented as an estimate.
That is the whole reason the number is hidden. It is not a theme decision and not a checkout setting you can flip: the first time most buyers see the shipping cost is step two of a three-step checkout, after they have typed their email and their street.
What the theme does about it is a single sentence. On Dawn, the product page prints "Shipping calculated at checkout." under the price — and only when the store has written a shipping policy, because the line is wrapped in {%- if shop.shipping_policy.body != blank -%} in sections/main-product.liquid. The cart page prints a variant of it from sections/main-cart-footer.liquid, choosing between eight strings in locales/en.default.json depending on whether taxes and duties are included and whether a policy exists. Horizon carries the same family, and one more: "Shipping discounts are shown at checkout after adding an address". Neither theme ships a free-shipping bar, a rate estimator, or any place to state a rate. Everything below is something you add.
The abandonment data says extra costs are the top fixable reason, and browsing is the top reason overall
Baymard Institute maintains a running list of cart abandonment statistics — fifty studies, averaged to 70.22 percent, last updated September 2025 — alongside its own survey of US online shoppers on why they abandoned. Read the two halves in order, because the first qualifies the second.
The first half: 42 percent said they abandoned because they were "just browsing / not ready to buy". That segment is not yours to recover, and the academic literature says the same thing from the other direction. Kukar-Kinney and Close, in the Journal of the Academy of Marketing Science, found that the factors shaping search, consideration and evaluation play a larger role in abandonment than anything at the purchase-decision stage, and that many people use the cart as a research and organising tool with no intention of buying in that session. Any figure that treats every abandoned cart as a lost order is inflating the prize.
The second half is the distribution once the browsers are set aside, and it is where shipping lives. The most cited reason was "extra costs too high (shipping, tax, fees)" at 40 percent. Second was "delivery was too slow" at 20 percent. Further down, 12 percent "couldn't see / calculate total order cost up-front". Three reasons on that list are about one fact — what the delivery costs and when it arrives — and all three are answered by putting the number where the decision is made rather than two screens after it.
What the data does not say is what showing it earlier is worth. Nobody has run that test on your store, and we will not quote a lift from someone else's. The measurement is your own checkout funnel — sessions that reached the shipping step against sessions that completed — before and after the change, over a period long enough to include a weekend. If the drop between those two steps shrinks, the fix worked.
Check what your store reveals, and where, in five minutes
Three places, in the order a buyer meets them.
The product page, at 390 pixels wide. Set DevTools to a phone-class viewport — 390 by 844 via the device mode toolbar — and load your best-selling product. Before the add to cart button, is there any statement about delivery cost or time? On Dawn, if there is not even the "Shipping calculated at checkout" line, your shipping policy is empty: Settings, Policies, Shipping policy. That is the first fix, below.
The cart. Open it. The line under the total is one of the eight strings above. If it says "calculated at checkout" with no number anywhere near it, the cart page is telling the buyer to go and find out.
The rate itself. You should know what the store would say. In the Console, with something in the cart, ask the same endpoint the checkout uses, for your main market:
fetch('/cart/shipping_rates.json?shipping_address[country]=France&shipping_address[zip]=75001')
.then((r) => r.json())
.then(console.log);
The response is a shipping_rates array with a name and a price for each rate the zone offers, per the Ajax cart reference. Run it for your top three destinations. If every answer is the same rate, or free above one threshold, your shipping is a sentence, and a sentence fits on a product page. If the answers differ by postcode and weight, you need an estimator, below. Then open Settings, Shipping and delivery, and count the zones and rates: that count is how complicated the sentence has to be.
The policy line is the cheapest fix, and the sentence above the button is the second
Write the shipping policy first. Settings, Policies, Shipping policy: what it costs, per zone, and how long it takes. Save it. On Dawn and Horizon the product page and the cart now link the word "shipping" to the policy page — the theme reads shop.shipping_policy.url, which the policy object exposes along with the body and the title. Ten minutes, no code, and the buyer who wants the number now has one tap to it instead of a checkout.
One tap is still one tap. The better version is the number itself, above the add to cart, for the market most of your orders come from. In the theme editor — Online Store, Themes, Customize, product template — add a text block to the main product section and drag it directly under the price: the delivery cost for your main country, the threshold above which it is free if you have one, and the delivery window. Three facts, one line. The sticky add to cart post makes the case for trust signals before the button rather than after it; this is the one that ranks first in the survey above.
If you sell into more than one country, a static line is right for one market and wrong for the rest. In theme code, localization.country returns the country the visitor has selected on the storefront, per the localization object, and a case on its iso_code prints the right sentence for each zone you ship to, with the policy link as the fallback. Keep the sentences in the section's settings rather than hard-coded, so whoever changes a rate in the admin can change the sentence without a deploy. The sentence and the rate drifting apart is the failure mode of this fix.
A free-shipping bar is arithmetic on cart.total_price, and it does not need an app
If you have a rate with a total-price condition, the bar is the honest way to show it: how far the cart is from the threshold, updated every time the cart changes.
The arithmetic is short. cart.total_price is the cart's total after discounts, in the currency's subunit — cents, not units — per the cart object. So a threshold entered in the section's settings in whole units is multiplied by 100 before the comparison:
{%- comment -%} snippets/free-shipping-bar.liquid {%- endcomment -%}
{%- assign threshold = section.settings.free_shipping_threshold | times: 100 -%}
{%- assign remaining = threshold | minus: cart.total_price -%}
{%- if cart.requires_shipping and threshold > 0 -%}
<div class="shipping-bar">
{%- if remaining > 0 -%}
<p>Add {{ remaining | money }} more for free delivery.</p>
{%- else -%}
<p>This order ships free.</p>
{%- endif -%}
<progress max="{{ threshold }}" value="{{ cart.total_price | at_most: threshold }}"></progress>
</div>
{%- endif -%}
Render it inside the cart drawer section and the cart page section, not in the header. Dawn's product form posts to /cart/add.js with a sections parameter listing the sections to re-render — assets/product-form.js builds that list from the drawer — and the response carries fresh HTML for each, the Section Rendering API bundled into the add request. A bar inside the drawer section is re-rendered on every add with no JavaScript of its own. A bar outside it goes stale.
Two conditions before you ship it.
The rate must exist. The promise is kept by a method definition in Settings, Shipping and delivery, with a condition on order price greater than or equal to the same number, in every zone the bar is shown in. A bar that says free and a checkout that says otherwise is the "extra costs too high" abandonment with a step of trust lost first.
The currency must match. cart.total_price is in the customer's presentment currency. If the store sells in several, one threshold is a number in one currency compared against a total in another. Either keep a threshold per market — a setting per country, selected with localization.country.iso_code — or show the bar only in the market whose currency the threshold is in.
An app does the same job with a script. Shopify's performance guidance states that JavaScript from installed apps runs on the main thread and competes directly with the browser's ability to render content and respond to input, and tells you to remove any script not earning its performance cost. A dozen lines of Liquid do not need to be a script. If an app is already installed, open the Network panel, filter to JS, and weigh it; the removal order is in why your Shopify store is slow.
An estimator asks for the destination the checkout would ask for anyway
When the rate genuinely varies — by postcode, by weight, by carrier — a sentence cannot say it, and the right fix is to let the buyer ask, from the cart, before checkout. The endpoints are the ones from the five-minute check. The Ajax cart reference recommends the two-call form because rates can take a moment to calculate: POST /cart/prepare_shipping_rates.json with the destination starts the calculation, and GET /cart/async_shipping_rates.json with the same parameters returns null until it is done and the rates once it is. The single-call GET /cart/shipping_rates.json also works, and is throttled.
async function estimate(country, province, zip) {
const q = new URLSearchParams({
'shipping_address[country]': country,
'shipping_address[province]': province,
'shipping_address[zip]': zip,
}).toString();
await fetch('/cart/prepare_shipping_rates.json?' + q, { method: 'POST' });
for (let i = 0; i < 10; i++) {
const body = await fetch('/cart/async_shipping_rates.json?' + q).then((r) => r.json());
if (body && body.shipping_rates) return body.shipping_rates;
await new Promise((resolve) => setTimeout(resolve, 500));
}
return [];
}
Each rate comes back with a name, a price as a plain decimal string, and — for carrier-calculated rates — a delivery_range. Render the name, the price formatted the way the cart formats its total, and the range if it is there, because "delivery was too slow" is the second reason on the list and a date answers it.
Keep the form to what the rate actually depends on. If your zones are countries, ask for the country and nothing else, pre-filled from localization.country; add the postcode only where a rate in the admin uses it. Every field is one the buyer fills twice, here and at checkout, and the form's whole argument is that it asks less than the checkout does. The estimator needs a cart with something that requires shipping — cart.requires_shipping is the flag — so it belongs on the cart page and in the drawer, not on the product page.
Google can show the shipping cost before the visitor arrives
There is one more place the number can go: the search result. Google's merchant listing documentation says that OfferShippingDetails on a product page's Offer enables people to see shipping costs and estimated delivery timeframes based on their location, and lists what the shipping enhancement requires: a shippingDestination with an addressCountry, a shippingRate with a value — zero for free — and a currency matching the offer's, and a deliveryTime. The type is defined at schema.org/OfferShippingDetails.
Shopify's structured_data filter, which is all the product markup Dawn and Horizon emit, carries no shipping properties — the product schema post lists exactly what it does carry. So it is either markup you add or a setting elsewhere, and Google states an order of precedence: product-level feeds in Merchant Center first, then Content API settings, then Merchant Center or Search Console settings, then product-level markup, then organisation-level markup. The same page recommends a global shipping policy under Organization markup over per-product detail, and notes that account-level shipping settings can be configured in Search Console and are added to Merchant Center automatically.
The practical reading: if your shipping is a sentence, put it in Search Console's shipping settings and skip the markup. If it varies by product, add shippingDetails to the offer in the JSON-LD snippet that carries your product markup, and check it with the Rich Results Test. A shipping cost in the result is the earliest the number can appear, and it costs nothing at page load.
Fix it in this order; the first step takes ten minutes
First, the policy. Settings, Policies, Shipping policy, written with the real rates and windows. Ten minutes, and the theme links to it from the product page and the cart on its own.
Second, the sentence. One text block under the price in the theme editor with the cost, the threshold and the window for your main market. Fifteen minutes. This is the one that answers the top reason in the survey, on the page where the decision is made.
Third, the bar, if and only if a threshold rate exists in the admin. An hour in theme code, inside the cart sections, one threshold per currency.
Fourth, the estimator, if and only if the rate varies by destination. An afternoon, on the cart page and in the drawer, asking for the fewest fields the rates need.
Fifth, Search Console shipping settings, or OfferShippingDetails if the cost varies by product. Half an hour, and a re-test in the Rich Results Test.
Then read the funnel. Shipping step reached against checkout completed, before and after, from your own analytics. That number, not anyone's percentage, is the result.
This is one line in the mobile buying path of the store audit checklist — trust signals before the button, not after it. If you would rather have that path walked and this ranked against everything else on the store, that is the Store Teardown: €750, five days, and the document is yours whatever you decide.