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
| Label | What it means | Typical Shopify cause |
|---|---|---|
| Direct | A 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 |
| Unassigned | A source and medium exist, but match none of GA4’s channel rules | Non-standard UTMs (utm_medium=klaviyo), or events with partial session data |
| (not set) | GA4 has no value at all for the dimension | Purchases 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.
- 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.
- 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.
- 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. - 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.
- 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
_gaby 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.comfor 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:
- On each visit, the server container receives the
_gavalue (and, for ads,_fbp,_gcl_awand similar). - 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.
- When a later request arrives without
_ga(because Safari deleted it), the server finds the backup and writes the original value back. - 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
_gaand 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
- In a private window from a European location, accept the banner on the home page.
- On a product page, check a GA4 request:
gcs=G111. - Go to the checkout and the thank-you page: GA4 requests there must also show
gcs=G111, with the samecid. - Repeat with a refusal:
gcs=G100everywhere, or no requests at all in basic mode. - 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_passthroughin 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 likeklaviyo,newsletter,igorPaid_Socialdon’t match its rules, so the session is Unassigned. Useemail,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
- Purchase sessions that start on the checkout: measured, and close to zero.
- One GA4 implementation for store and checkout, same
cidandsidon product page and thank-you page. page_locationandpage_referrerfromevent.context.documenton every hit.- Consent read from the Customer Privacy API in the pixel; same
gcson store and checkout. - Server-side purchases carry
client_idandsession_id, or stay browser-based. - Cookie restoration on server-side GTM if Safari is a large share of your traffic.
- Payment domains in unwanted referrals; cross-domain set up if you use several domains.
- A UTM convention used everywhere, with standard
utm_mediumvalues. - Offline, admin and subscription orders filtered or labelled.
- 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.