Shopify purchases attributed to Direct or Unassigned in GA4: every cause and how to fix it

Why GA4 credits Shopify sales to Direct, Unassigned or (not set): the checkout sandbox, cookie lifetime and restoration, consent between store and checkout, and every other cause.

You open GA4, look at purchases by channel, and a large share of your Shopify revenue sits in Direct or Unassigned, sometimes with (not set) next to it. Your customers didn’t all type your URL from memory. Most of them came from an ad, an email or a search, and GA4 lost that information somewhere between the landing page and the thank-you page. This guide covers every cause I find on Shopify stores, how to measure which ones affect you, and how to fix them, with a special focus on the three that other guides skip: the checkout sandbox, cookie lifetime and restoration, and consent shared between your website and the sandbox.

Direct, Unassigned and (not set): what GA4 is telling you

LabelWhat it meansTypical Shopify cause
DirectA session exists, but with no source: source is (direct) and medium (none)The visitor wasn’t recognized at checkout, so the purchase started a new session with no referrer
UnassignedA source and medium exist, but match none of GA4’s channel rulesNon-standard UTMs (utm_medium=klaviyo), or events with partial session data
(not set)GA4 has no value at all for the dimensionPurchases sent without a session: server-side events, admin and POS orders, subscription renewals

One detail matters for diagnosis. In GA4’s key-event attribution, Direct only gets the credit when GA4 knows no earlier non-direct visit for that user. So a high Direct share for purchases usually means GA4 lost the user’s history, not just the last click. Keep that in mind when you read the causes below: most of them are identity problems.

Step 1: measure the problem before fixing it

There is no universal “normal” share of Direct revenue. Compare against your own history and against Shopify, and look for the patterns below.

  1. Trend. In Reports → Acquisition → Traffic acquisition, set the dimension to Session default channel group and look at purchases and revenue for Direct and Unassigned over the last 12 months. A sudden jump on a specific date points to a change: a new theme, a checkout app, a cookie banner, a migration to Customer events.
  2. Compare with Shopify. Shopify’s own reports (sales by traffic source or referrer) use Shopify’s attribution, not GA4’s. If Shopify shows far less direct revenue than GA4, the loss is in your GA4 setup.
  3. Landing page of purchase sessions. In an exploration, add Landing page + query string and filter on the purchase event. If many purchase sessions “land” on /checkouts/... or the thank-you page, the session broke during checkout. This is the single most useful check.
  4. Browser. Break Direct purchases down by browser. If Safari is heavily over-represented compared with your traffic, cookie lifetime (ITP) is part of the problem.
  5. New vs returning. If Direct purchases are mostly “new users” who are in fact returning customers in Shopify, GA4 lost their client ID.

With the GA4 BigQuery export, you can quantify broken sessions precisely. This query counts purchases by how their session started:

WITH events AS (
  SELECT
    user_pseudo_id,
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS session_id,
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location,
    event_name, event_timestamp, ecommerce.transaction_id
  FROM `your-project.analytics_123456789.events_*`
  WHERE _TABLE_SUFFIX BETWEEN '20261101' AND '20261130'
),
landing AS (
  SELECT user_pseudo_id, session_id,
    ARRAY_AGG(page_location ORDER BY event_timestamp LIMIT 1)[OFFSET(0)] AS landing_page
  FROM events
  WHERE event_name = 'page_view'
  GROUP BY 1, 2
)
SELECT
  CASE
    WHEN l.landing_page IS NULL THEN 'no page_view in session (server-side or cookieless)'
    WHEN REGEXP_CONTAINS(l.landing_page, r'/checkouts?/') THEN 'session started in checkout'
    ELSE 'session started on the store'
  END AS session_start,
  COUNT(DISTINCT e.transaction_id) AS purchases
FROM events e
LEFT JOIN landing l USING (user_pseudo_id, session_id)
WHERE e.event_name = 'purchase'
GROUP BY 1
ORDER BY purchases DESC;

Anything in the first two rows is a purchase GA4 couldn’t connect to the visit that produced it.

Cause 1: the checkout sandbox breaks the visit

On today’s Shopify, nothing runs on the checkout except pixels, and custom pixels run in a sandboxed iframe. That sandbox is the most common source of Direct purchases I find in audits, for four reasons.

Two GA4 implementations that don’t share an identity

A very common setup: GA4 (or GTM) hardcoded in theme.liquid for the store, plus a custom pixel or the Google & YouTube app for the checkout. Each sends events with its own logic. If they don’t read and write the same _ga cookie for the same measurement ID, the checkout events arrive with a different client ID or session ID, and the purchase opens a new session with no source.

Fix: load GA4 from one place only. My recommendation is a single custom pixel that tracks every page, store and checkout, so the whole journey uses the same tag and the same cookies. Then check continuity: read the cid and sid parameters of GA4 requests (Network tab, filter collect) on a product page and on the thank-you page. They must be identical.

The sandbox’s own page address

Inside the sandbox, window.location and document.referrer describe the iframe, not your store. If GA4 reads them, the landing page loses its UTM parameters and gclid, and the referrer is wrong. The session that should be “google / cpc” becomes Direct, or a self-referral.

Fix: always pass the real page data from the event context, on the Google tag and on every event:

analytics.subscribe("page_viewed", (event) => {
  const doc = event.context.document;
  window.dataLayer.push({
    event: "page_view",
    page_location: doc.location.href,
    page_referrer: doc.referrer,
    page_title: doc.title
  });
});

Then confirm in DebugView that the first page_view of a test visit from a tagged URL carries the full URL with its UTMs.

Visits that start on the checkout

Abandoned-checkout emails and SMS, “Buy it now” buttons and express checkouts (Shop Pay, Apple Pay, Google Pay, PayPal) can take a visitor straight to the checkout. If that checkout page doesn’t send a proper page_view with its UTMs, the purchase session has no source. Some accelerated checkouts also skip parts of the normal flow, so test each one.

Fix: make sure the pixel sends page_view on checkout pages with the real URL, tag every recovery email and SMS link with UTMs, and where you can, point recovery links to the cart rather than directly to the checkout.

Server-side purchases without the session

If purchases are sent to GA4 from a server (a server-side GTM purchase from a webhook, the Measurement Protocol, or a server-to-server connection from an app), GA4 needs the browser’s client_id and session_id to place the purchase in the right session. A matching client ID alone isn’t enough: without the session ID, the purchase opens a new session at Direct, or lands in (not set).

Fix: capture both IDs in the browser (from the _ga and _ga_<container-id> cookies) during the visit, store them with the order (cart attributes or order notes), and send them with the server-side purchase. Or keep the purchase browser-based and use server-side tracking for the ad platforms only.

Cause 2: cookies that expire before the customer buys

GA4 recognizes a returning visitor through the _ga cookie. If that cookie disappears between the ad click and the purchase, the customer comes back as a new user, GA4 has no earlier non-direct visit to credit, and the purchase goes to Direct.

What Safari and other browsers do

  • Cookies written by JavaScript (which is how GA4 sets _ga by default) are capped at 7 days by Safari’s Intelligent Tracking Prevention, and can be capped at 24 hours when the visitor arrives from certain tracking links.
  • Cookies set by a server on a subdomain (for example data.yourstore.com for server-side GTM) are also capped when the subdomain’s IP address doesn’t match your main site’s. On Shopify, your store is served by Shopify’s infrastructure and your server container lives elsewhere, so this restriction usually applies.
  • Firefox, Brave and many in-app browsers add their own limits.

So a Safari customer who clicks a Meta ad, thinks for ten days, and comes back via a bookmark to buy, is a new user with a Direct purchase. For stores with long consideration times and many iPhone users, this alone moves a noticeable share of revenue to Direct.

How cookie restoration works

Cookie restoration is a server-side GTM feature that keeps a backup of your tracking identifiers and puts them back when the browser loses them:

  1. On each visit, the server container receives the _ga value (and, for ads, _fbp, _gcl_aw and similar).
  2. It stores a copy, either in a cookie set by the server with a long lifetime, or in a server-side store keyed to the visitor.
  3. When a later request arrives without _ga (because Safari deleted it), the server finds the backup and writes the original value back.
  4. GA4 receives the original client ID, sees the earlier ad visit, and credits the campaign instead of Direct.

Addingwell includes cookie restoration by default; on Stape it’s available as a power-up you configure. I compare them in Addingwell vs Stape.

What it can and can’t do

  • It needs server-side GTM, set up on your own subdomain.
  • It only works for visitors who consented: restoring an identifier for someone who refused is not an option.
  • It can’t recover visitors who switch device or browser, or who clear everything.
  • How much it recovers depends on your share of Safari traffic and how long customers take to buy. Measure Direct purchases on Safari before and after.

For the full picture of server-side tracking on Shopify, see Shopify server-side tracking: what it fixes, what it doesn’t, and what it costs.

Cause 3: consent that isn’t shared between the website and the checkout sandbox

The banner type matters too: with a non-blocking banner, visitors who accept on their second page start a tracked session without their landing page. Banner choices and their effect on attribution are in how to set up a cookie consent banner on Shopify.

This is the cause I see most often in European stores, and it’s invisible unless you look for it.

Two pages, two data layers

Your cookie banner runs on the theme. When the visitor accepts, it typically updates Consent Mode in the theme’s dataLayer. But GTM inside a custom pixel has its own, separate dataLayer in the sandbox, and never sees that update. Unless something bridges the two, the sandbox keeps its defaults:

  • Defaults denied in the sandbox: the visitor accepted on the store, but on the checkout GA4 runs without cookies. The purchase arrives without the visitor’s _ga and can’t be tied to the session: Direct, or (not set).
  • Defaults granted in the sandbox: purchases are tracked for visitors who refused, which is a compliance problem, and the visit itself (tracked with consent rules on the theme) and the purchase don’t match.

The bridge: Shopify’s Customer Privacy API

The visitor’s choice reaches the sandbox only through Shopify. Your banner must write it to the Customer Privacy API (Shopify’s own banner and most Shopify-integrated banner apps do), and your custom pixel must read it on load and listen for changes, before GTM loads:

window.dataLayer = window.dataLayer || [];
function gtag() { window.dataLayer.push(arguments); }

const consentState = (p) => ({
  analytics_storage: p.analyticsProcessingAllowed ? "granted" : "denied",
  ad_storage: p.marketingAllowed ? "granted" : "denied",
  ad_user_data: p.marketingAllowed ? "granted" : "denied",
  ad_personalization: p.marketingAllowed ? "granted" : "denied"
});

gtag("consent", "default", { ...consentState(init.customerPrivacy), wait_for_update: 500 });
customerPrivacy.subscribe("visitorConsentCollected", (event) => {
  gtag("consent", "update", consentState(event.customerPrivacy));
});
// load GTM after this

The complete setup is in Consent Mode v2 on Shopify with the Customer Privacy API.

Test that the store and the checkout agree

  1. In a private window from a European location, accept the banner on the home page.
  2. On a product page, check a GA4 request: gcs=G111.
  3. Go to the checkout and the thank-you page: GA4 requests there must also show gcs=G111, with the same cid.
  4. Repeat with a refusal: gcs=G100 everywhere, or no requests at all in basic mode.
  5. In the console, Shopify.customerPrivacy.currentVisitorConsent() must match what you clicked.

If the theme and checkout disagree, you have found a source of Direct purchases. The five-minute version of this test is in does your cookie banner actually reach your pixels?

Consent given after the landing page

A subtler effect: a visitor lands from an ad with UTMs, ignores the banner, browses, and accepts on the second or third page. In basic Consent Mode, nothing was sent from the landing page, so the first hit GA4 receives is a page without UTMs and with your own site as referrer: the session starts as Direct. In advanced mode, the cookieless ping from the landing page doesn’t carry an identifier that links it to the later session.

Ways to reduce it:

  • A banner that’s clearly visible on the landing page, so most choices happen there.
  • url_passthrough in Consent Mode, which keeps ad click identifiers in internal links until consent is given.
  • Storing the landing page’s campaign parameters for consenting visitors and sending them with the purchase as a backup, as below.

Cause 4: payment redirects and other domains

PayPal, Klarna, 3-D Secure bank pages and Shop Pay can take the buyer to another domain before the thank-you page. When they come back, the referrer is the payment provider, and if the session expired while they paid, a new session starts. That usually shows as paypal.com / referral, but combined with a lost cookie it shows as Direct. Fix it with GA4’s unwanted referrals list: see fixing paypal.com / referral in GA4.

If your store uses several domains (one per market, or a separate checkout domain), set up cross-domain measurement in GA4 so the client ID travels with the visitor.

Cause 5: traffic that arrives without a source

Some Direct and Unassigned revenue comes from visits that never carried a source in the first place:

  • Untagged links: email and SMS (Klaviyo, Omnisend), social bios, influencer links, QR codes, WhatsApp messages. Without UTMs, they’re Direct.
  • Non-standard UTMs: GA4 classifies channels mainly from utm_medium. Values like klaviyo, newsletter, ig or Paid_Social don’t match its rules, so the session is Unassigned. Use email, sms, cpc, paid_social, social, affiliate, in lowercase.
  • Broken UTMs: a second ? in the URL, spaces, or a redirect that strips the query string.
  • In-app browsers: Instagram, TikTok and Facebook’s in-app browsers often send no referrer, and their cookies don’t persist into Safari or Chrome. Tag every paid and organic social link.
  • Google Ads not linked: if Google Ads isn’t linked to GA4 or auto-tagging is off, paid clicks lose their campaign information.

Fix: write a short UTM convention, use it everywhere (ads, emails, SMS, social, influencers), and check Unassigned by session source / medium every month to catch new exceptions.

Cause 6: orders that never had a visit

Some orders reach GA4 without any browser session behind them:

  • Draft orders and orders created in the admin.
  • POS sales, if anything sends them to GA4.
  • Subscription renewals (Shopify Subscriptions, Recharge, other apps).
  • Orders from other sales channels: Shop app, marketplaces, social commerce.
  • Server-to-server purchases sent by an app when the browser event is missing.

These are structural: they will always be (not set) or Unassigned in GA4. Fix: keep them out of GA4 (filter them at the source), or send them with a clear manual source such as source=pos, medium=offline so they’re labelled, not mixed with lost web purchases.

Cause 7: smaller things that add up

  • Duplicate GA4 tags with different settings, one of which has no consent or page data. See duplicate purchase events.
  • Ad blockers and privacy browsers that block GA4 on the store but not a server-side purchase, which then arrives alone.
  • Session timeout during a long checkout. The default 30 minutes is usually fine; extend it only if your buyers really take longer.
  • A new property or data stream: during the first days, more traffic is unattributed while GA4 builds history.
  • Processing delay: Realtime and recent days show more Unassigned until GA4 finishes processing (24 to 48 hours).

A backup: send the campaign with the purchase

Even with everything fixed, some sessions will break. For consenting visitors, you can store the last campaign seen on the store and send it with the purchase as event parameters. It doesn’t change GA4’s channel attribution, but it gives you a second view in explorations to see where “Direct” purchases really came from:

const CAMPAIGN_KEYS = ["utm_source", "utm_medium", "utm_campaign", "gclid", "fbclid", "ttclid"];

analytics.subscribe("page_viewed", async (event) => {
  if (!init.customerPrivacy.analyticsProcessingAllowed) return;
  const url = new URL(event.context.document.location.href);
  const found = {};
  CAMPAIGN_KEYS.forEach((k) => {
    const v = url.searchParams.get(k);
    if (v) found[k] = v;
  });
  if (Object.keys(found).length) {
    await browser.cookie.set(
      "last_campaign=" + encodeURIComponent(JSON.stringify(found)) + "; path=/; max-age=2592000; SameSite=Lax"
    );
  }
});

analytics.subscribe("checkout_completed", async (event) => {
  const raw = await browser.cookie.get("last_campaign");
  const last = raw ? JSON.parse(decodeURIComponent(raw)) : {};
  window.dataLayer.push({
    event: "purchase_campaign",
    last_utm_source: last.utm_source,
    last_utm_medium: last.utm_medium,
    last_utm_campaign: last.utm_campaign,
    last_click_id: last.gclid ? "gclid" : last.fbclid ? "fbclid" : last.ttclid ? "ttclid" : undefined
  });
});

Merge these parameters into your purchase event in GTM and register them as custom dimensions. Note that this cookie is set by JavaScript, so Safari’s limits apply to it too.

Checklist

  1. Purchase sessions that start on the checkout: measured, and close to zero.
  2. One GA4 implementation for store and checkout, same cid and sid on product page and thank-you page.
  3. page_location and page_referrer from event.context.document on every hit.
  4. Consent read from the Customer Privacy API in the pixel; same gcs on store and checkout.
  5. Server-side purchases carry client_id and session_id, or stay browser-based.
  6. Cookie restoration on server-side GTM if Safari is a large share of your traffic.
  7. Payment domains in unwanted referrals; cross-domain set up if you use several domains.
  8. A UTM convention used everywhere, with standard utm_medium values.
  9. Offline, admin and subscription orders filtered or labelled.
  10. Monthly check of Direct and Unassigned purchases by browser, landing page and source / medium.

Frequently asked questions

Why does GA4 show more Direct revenue than Shopify?

Shopify attributes orders from its own session data on the store, which isn’t affected by your GA4 setup. When GA4’s identity breaks at checkout, or cookies expire, GA4 falls back to Direct while Shopify still knows the source. A large gap between the two is a sign of a tracking problem, not of real direct traffic.

Is some Direct traffic normal?

Yes. Returning customers who type your URL, bookmarks, and some apps are genuinely direct. What’s not normal is a sudden increase after a change, purchase sessions that start on the checkout, or Direct purchases concentrated on Safari.

Does server-side tracking fix Direct and Unassigned purchases?

Partly. Server-side GTM with cookie restoration helps with Safari’s cookie limits, and a well-built server-side purchase can keep the session. But a server-side purchase sent without the session ID makes things worse, and server-side tracking doesn’t fix consent, UTMs or two GA4 implementations fighting each other.

What’s the difference between Unassigned and (not set)?

Unassigned means GA4 has a source and medium but they match no channel rule, usually because of non-standard UTMs. (not set) means GA4 has no value at all, usually because the event arrived without a session, such as a server-side or offline order.

Can I fix past data?

No. GA4 doesn’t reprocess history. Fixes apply from the day they go live, so note the date in your reports and compare periods before and after.

Need help finding where your sessions break?

Attribution is one of the eight areas of my Shopify tracking audit (from 800€), where I measure each cause above on your store and give you a prioritized fix list. My Professional package (3,500€) sets up the full chain: a single custom pixel, Consent Mode v2 shared with the checkout, and server-side GTM with cookie restoration. See the Shopify tracking packages or book a free 30-minute call.

Elliot Latil

Full Stack Web Analytics expert: GA4, GTM, server-side tracking and Shopify. About me