본문으로 건너뛰기
Back to blog
GTM

I Logged In and Every User Property Was undefined: GTM and GA4 from Setup to Revenue Analysis

18 min readGTM
GTMGA4case study
I Logged In and Every User Property Was undefined: GTM and GA4 from Setup to Revenue Analysis

Audience: Marketers  ·  Growth  ·  Data   ·   Topic: GTM User Properties, Setup and Analysis  ·  2026-06-18

I Was Definitely Logged In. Why Is Everything Empty?

I had just finished setting up front-end tracking. I'd planted all the code to push values, user ID, membership status, marketing consent, into the dataLayer (the channel a web page uses to pass data to analytics tools). Time to verify. I opened GTM (Google Tag Manager) Preview and logged into the site.

Then I opened the GA4 Configuration tag (the core tag that initializes analytics when the site loads) and looked at the user properties section. This is what it showed.

NameValue
user_idundefined
membership_statusundefined
is_logged_inundefined
device_idundefined
marketing_consentundefined

I was clearly logged in. I was authenticated as a member, yet not a single value identifying that member was present. Did I plant the code wrong? Did I misspell a variable? I stared at that screen for a long time. That was the beginning. This post is the story of how that undefined turned out to be not a bug, but a mirror reflecting exactly how GA4 works.

The Critical Clue: page_category Was Empty Too

The variable names were correct. The code was writing values. So why. Looking again at the screen, I noticed page_category above the user properties was also undefined. That value comes from a completely different source than user data, it holds which category a page belongs to, and it comes in as the page renders.

Two values from entirely different sources, both empty. They shared one thing in common: both arrive "later." That's when my suspicion shifted for the first time. What if the values weren't missing, what if the tag had fired before the values arrived? I printed window.dataLayer in the browser console and spread out the timeline. The picture appeared all at once.

The config tag camera fired the shutter before the subject arrived No values in dataLayer yet From here: user_id = '1' Click (too early) Photo taken user_id: ? Login values arrive gtm.js config tag fires page_view /login set: anonymous undefined set: logged in user_id: '1' login event '1' read page_view /home '1' At the moment the shutter fired (far left), the subject hadn't arrived yet. undefined is a blank photo, not a broken camera. The value wasn't missing -- the order was just off.
dataLayer timeline. The config tag fires the moment the container loads (gtm.js), but login context arrives much later.

Why does it arrive late? The config tag fires at gtm.js, the earliest possible moment when the container loads. Confirming login state, on the other hand, happens after the front end (an SPA like React) has rendered the page and verified authentication. That's async. The code that knows who the user is doesn't push values into the dataLayer until after the page is up. The undefined the config tag saw was an accurate snapshot of that exact moment. The value wasn't wrong, the photo was taken too early.

Two Dead Ends: Stuffing the Config Tag, Then Creating a New Event

I knew the cause, so I just needed to fix it. But here I took two wrong turns. Embarrassing as they are, I'm keeping them, partly to avoid wandering the same path again, and partly because these two detours are actually the fastest route to the core concepts.

First wrong turn. "Then I just need to set the config tag values more carefully." No. Values set in the config tag (GA4's Google tag) are read exactly once, at the moment the tag fires, and then they're frozen. Static. Since it fires before login, no matter how carefully you set those values, they'll be undefined forever. It can't be fixed without changing where you put the values.

Second wrong turn. "Then I'll create a new event that signals login context is ready and fire the tag off that." It sounded reasonable. I spent a while designing it. But when I checked against real-world practice, it was overkill. No separate event is needed, because the dataLayer accumulates. This "accumulation" is the second most important concept in this post, so let me pin it down precisely.

Cumulative dataLayer: Values That Come In Never Disappear

The dataLayer is not a variable that gets overwritten, it's a model that accumulates and merges. Expanding the array in the console shows: anonymous context comes in first (user_id: undefined), then once login is confirmed, an object containing user_id: '1' is pushed on top. GTM's dataLayer variables read the latest value from this accumulated state. So once a login value is pushed, every event tag that fires afterward reads that value directly. I didn't need a new event, I just needed to move where I was reading the value.

There's a hidden trap here, so let me flag it now. There are two versions of the dataLayer variable. The default Version 2 merges and holds accumulated values, but Version 1 treats any key absent from that push as undefined. If you build the variable as Version 1, the accumulation benefit disappears and values look empty again. Variables for user properties must be Version 2. Without knowing this, you'll hit another wall: "I definitely moved it to the event settings, so why is it undefined again?"

Only after two wrong turns did the core insight appear. I had kept asking "which tag do I put this in?" But user properties were never meant to work that way.

The Turning Point: User Properties Attach to People, Not Tags

This is the heart of the post. GA4 data has a concept called scope. Without understanding it, you'll keep fixing the wrong thing indefinitely. The GA4 official docs define user properties as "attributes that describe groups of your user base." They describe people, not actions. Put them side by side with event parameters and the nature of each becomes completely clear.

A fresh receipt each time; a membership card that follows the person Event Parameters = Receipt (action) page_view login generate_lead Records each action as it happens One receipt per action. The next action gets a new one. Scope: event (event-scoped) User Properties = Membership Card (person) 1st visit 2nd visit 3rd visit Once set, always follows membership: member · persists until changed Scope: user (user-scoped)
Event parameters are a fresh receipt for each action; user properties are a membership card the person carries. Once set, it persists through the next visit.

Here comes the decisive property. Once a user property arrives in GA4, it is saved to that person and automatically attached to all subsequent events. It persists until the value is changed. So you don't need to include it in every single tag. Once the value "member" arrives during the login event, GA4 stamps that person and handles the rest automatically.

Only then did I fully understand why the initial undefined wasn't a problem. The config tag being empty is fine. Once the login event delivers the value once, that's all it takes. The config tag screen I had stared at for days was a place values could never enter in the first place. I was standing at the wrong window, complaining it was dark inside.

So Where Do You Put It: A Single Event Settings Variable

Move where you read the value from "the config tag that fires once" to "events that re-read on every fire." That doesn't mean copying and pasting the same values into every tag. The GTM official docs provide exactly the right tool for this: the Event Settings Variable. Build it once and have every GA4 event tag reference it.

A frozen Polaroid vs. a live gauge that re-reads on every fire Config Tag = Static Polaroid undefined gtm.js, one-time snapshot Reads the value at fire time and freezes Pre-login means an empty photo, frozen forever Event Settings Variable = Live Gauge Recalculated on every event Re-reads the value on every send (dynamic) Events after login carry the real value
The config tag value is a Polaroid that freezes after one shot; the Event Settings Variable is a live gauge whose needle turns again on every fire.

The key is this one line from the official docs: "Unlike values set by a configuration tag, the values of an Event Settings Variable are dynamically recalculated each time an event is sent. They are not tied to static values." Dynamic recalculation is the root fix for the problem we started with.

Here's how to set it up. In GTM, create one variable of type "Google tag: Event Settings." In the User Properties section, wire up is_logged_in, membership_status, device_id, and marketing_consent, each as a dataLayer variable. Just once. Then in each GA4 event tag, simply select this variable in the Event Settings Variable field. If you ever need to change a property later, updating that one variable propagates to every tag.

Does that mean you don't need to include anything in the config tag at all? There's a nuance here. The short answer: having both is the standard practice. For flows like this session, entering logged out, then logging in, the Event Settings Variable handles it. But for returning visits where the user is already logged in, the config tag may fire when values are already present, so the first page view immediately carries user_id. That's why the practitioner guide recommends "put it in the config tag, and also in all event tags as a safety net." The config tag owns the first hit of a returning visit; the Event Settings Variable owns the current session.

Verification became clear too. In Preview, I stopped looking at the config tag (gtm.js). Instead, I opened an event that fired after login, the login tag, and there it was: user_id: '1', membership_status: 'member', sharp and clear. Changing where I was looking was all it took.

One small scar. Looking further at the real timeline, marketing_consent settled to true a beat after the login event. The user object arrived first; the consent value came slightly later. So the login event tag might still see consent as false. If you're gating anything on consent, don't rely on the dataLayer value at event time, have the server read the source directly just before sending. Fix the timing in one place, and it leaks somewhere else.

What I Thought Was a Side Note: user_id Is Not a User Property

The four properties were sorted out, but user_id kept behaving differently. It turned out user_id is not a user property, it's a reserved identifier in GA4. Not an attribute describing "what kind of person this is," but the identity backbone that determines "whose data this belongs to." So it goes in a different place: not in the user properties field, but in the dedicated user_id field of the Google tag.

Taking one more step in, the full picture of how GA4 identifies users came into view. GA4 doesn't determine identity from a single ID. As the Reporting identity docs explain, it uses a priority ladder: use the top; if it's not available, fall to the next.

GA4's identity ladder: use the top, fall to the next if not available 1st priority. User-ID Your assigned login ID. Most accurate. 2nd priority. Device ID (client_id) Cookie identifier auto-generated by GA4. 3rd priority. Modeling Estimates non-cookie users by similarity. Trap: Two things called device_id GA4 Device ID = client_id, auto enters the identity ladder Our device_id UUID we generated User property (join key) Same name, completely different. GA4's is used for identity; ours is just a label for analysis and joining. Mixing them breaks both identity and analysis entirely.
GA4 identifies users in this order: User-ID, Device ID (client_id), then modeling. Our device_id shares the name with GA4's Device ID but is an entirely separate value.

Google signals used to sit in this ladder too, but Google has removed it from the identity use case. The ladder now has these three tiers. Choosing how far down to use it is the Reporting identity setting. There are three modes. Blended starts with User-ID, falls to device ID if absent, and falls to modeling if still absent. Observed drops modeling and uses only real identifiers. Device-based ignores User-ID entirely and uses only device ID. If member analysis matters, you need Blended or Observed, otherwise the user_id you worked hard to send gets discarded. You also need to enable User-ID reporting in GA4 Admin for cross-device identification to surface in reports.

What Is That UUID?

Our device_id value looks something like a48944fc-b19c-4557-8f2f-7fe0d0da4f36. This is a UUID (Universally Unique Identifier): a random 128-bit value designed so that no matter who generates it, when, or where, it essentially never collides with another. The number of possible values is astronomical, so the browser can generate and store one without checking a central server, with no worry of duplicates.

A parcel tracking number with no recipient name a48944fc-b19c Random 128-bit Can chain all actions from the same device No name or phone -- not PII (safe) Real identifiers like phone numbers are not allowed The number has no recipient, but it lets you track the same parcel. That's why it's safe as a user property.
A UUID is a tracking number with no recipient. It can chain actions together, but it doesn't point to a person on its own, so it's safe to store as a user property without being PII.

What matters is that this value contains no name, no phone number, it's a meaningless random number. So storing it as a user property is safe; it's not personally identifiable information (PII). But putting a real identifier there, a phone number, for example, is a different story. Even SHA-256 hashing a phone number does not make it "anonymous" under Korean law; it becomes pseudonymous data. The phone number space is small enough that anyone with a matching table can reverse it. So any value that actually points to a real person must be handled through a separate channel (e.g., server-side conversion sending), with separate consent and disclosure, not as a user property. The tracking number is fine; the recipient's name is not.

The Final Setup Trap: Sending Isn't Enough

Even if you send values correctly from GTM, they won't appear in GA4 reports. This is where first-timers always get stuck a second time. "Sending the value" and "making it visible in reports" are separate steps.

You need both steps before it shows in reports Step 1 · GTM Pack into an Event Settings Variable, send to GA4 Step 2 · GA4 Admin Register as user-scoped custom dimension Analyze in Reports Explorations, audiences, segments for slicing Step 1 only: data accumulates but never shows in reports. The mystery of "why can't I see it?" 24-48 hrs to reflect · Name must match exactly (case-sensitive) · 25 user properties per property
Sending via GTM (Step 1) is not enough. You must register it as a user-scoped custom dimension in GA4 Admin (Step 2) for it to appear in reports.

The GA4 official docs put it this way: "Adding a user property to your code allows a user-scoped custom dimension to unlock the ability to analyze that data." Without registering, data comes in but is invisible in reports. There's one more thing to pin down here. Custom dimensions have two scopes: event scope and user scope. Our four properties must be registered as "User" scope. Registering them as event scope by mistake means they only attach to that event and don't accumulate against the person. The limits differ too: 25 per property for user scope, 50 for event scope.

Here's the procedure. In GA4 Admin, go to Custom Definitions. Set scope to "User," type the exact same name as what GTM sends, character for character, and register all four. Names are case-sensitive. membership_status and Membership_status are treated as different properties. Expect 24 to 48 hours for data to reflect. While you're waiting, use GA4 DebugView to confirm user properties are being recorded in real time. No need to wait for reports.

That's the Plumbing. Now Let's Look at Revenue.

Setup is the means; analysis is the purpose. With user properties flowing in, you can now slice data by the person. The moment you cut by person instead of action, the gap hidden in the average surfaces, and where to put budget becomes visible.

One membership_status field reveals the conversion gap hiding in the average Seen as one group, the average masks the gap Member 8.0% Non-member 1.5% $ Shift the budget Toward the higher-converting segment Numbers are illustrative. The point: segment to see the gap, and use the gap to justify moving budget.
Viewing everything as one group hides the gap in the average. The moment you split by membership status, the conversion gap surfaces, and that gap justifies moving budget.

There are three concrete paths from user properties to revenue. The numbers are illustrative; the structure is the point.

1. Shift Budget with Segment Comparison

Split conversion rates between members and non-members using membership_status. If the consultation conversion rate for members is five times higher than for non-members, you have a basis for spending more on "driving signups to turn non-members into members" rather than "ads to bring in new traffic." That judgment is impossible from a single average conversion rate. An average always hides half the truth.

2. Warm Up the Closest Converts with Audiences

People who are is_logged_in = true but haven't yet submitted a consultation request. They've shown interest but haven't converted. Build a GA4 Audience on that condition and export it to your ad platforms, and you're targeting the people closest to conversion first. They're warmer than cold new visitors and easier to nudge. It's the cheapest lever for pulling more conversions from the same ad spend.

3. Expand Reach with the Consented Audience

Filter to members with marketing_consent = true and use them as the target for consent-gated channels like SMS or push notifications. Holding consent in data lets you reach exactly "only the people you're allowed to reach." It structurally prevents accidentally sending to non-consenting users, while ensuring everyone you can reach is reached. Reducing risk while expanding reach at the same time is rare, this is one of those cases.

One step further. With a join key like device_id, you can reconnect conversions that happen offline days later, signups, activations, contracts, back to the same person, attributing them to the ad that drove them. User properties are one piece of that person-level linkage. That topic deserves its own post, so I'll just point to the door here.

One honest note: this is the mechanism by which the design can lift revenue, not results I've measured myself. The 8% and 1.5% conversion rates above are illustrative numbers meant to show the structure. Real figures come only after one or two days of data accumulating against the registered dimensions. What is clear, though, is that without the ability to slice by person, none of these decisions can even begin. User properties are the minimum part that makes that slicing possible.

Key Takeaways: User Property Setup and Usage Checklist

If you approach this with an e-commerce pixel mindset, you'll lose days on "why is the config tag empty?" For any business where members and non-members are mixed and logged-in vs. logged-out states matter, working with user properties comes down to a handful of principles.

Checklist for Anyone Setting Up User Properties for the First Time
  • Don't panic at undefined in the config tag. The config tag fires at the earliest possible moment. Login values come after. Verify against events that fired after login, not the config tag.
  • User properties attach to people, not tags. Once they arrive, they're stored on that person and follow them. No need to copy-paste them into every tag. The cumulative dataLayer makes that possible.
  • Consolidate values in a single Event Settings Variable and share it. It's dynamically recalculated on every fire, so timing problems solve themselves. Putting values only in the static config tag is the original bug. Keep user_id in the config tag too as a safety net.
  • Keep dataLayer variables at Version 2. Version 1 can't read accumulated values and will produce undefined again.
  • user_id is an identifier, not a property. Put it in the dedicated field on the Google tag, and set Reporting identity to a User-ID mode (Blended or Observed) for it to be used in identification. Also enable User-ID reporting.
  • Don't confuse your device_id with GA4's Device ID. Same name, entirely separate values. Ours is a random UUID, not PII. But don't put real identifiers like phone numbers in user properties. Even hashed, they're pseudonymous data.
  • Setup is two steps. GTM sending + registering in GA4 as a "User"-scoped custom dimension. Step 1 alone means data accumulates invisibly. Names must match exactly (case-sensitive), limit is 25, verify with DebugView.
  • Setup is the means; segmentation is the goal. Split conversion rates by membership, build audiences from logged-in non-converters, message only those who consented. Segmenting by person is what creates the basis for moving budget.

It started with a single undefined on one screen, but at the end it turned out to be a question that could only be solved by understanding how GA4 identifies and stores people. Not laying down all the tracking first and adding analysis later, but mapping out "what will I ultimately segment with this data" from the very beginning. User properties are the starting point for that segmentation, and the most foundational part of viewing revenue at the person level. One blank photo led the whole way there.

User properties showing up as undefined usually trace back to never deciding what belongs as a property. The event taxonomy piece covers the reasoning that reduced nine candidates to one actually sent.

References

  • GA4 User properties, Google official help. Basis for the definition of user properties (attributes describing people), reserved names, 25-per-property limit, and case-sensitivity of names.
  • Create user-scoped custom dimensions in GA4, Google official help. Basis for collection (code/GTM) and registration (GA4 Admin) being separate steps, and registration being required for analysis in reports.
  • Reuse event settings in GTM (Event Settings Variable), Google official help. Basis for consolidating user properties in one variable shared across all event tags, and values being dynamically recalculated on each send.
  • GA4 Reporting identity, Google official help. Basis for the User-ID, Device ID (client_id), modeling priority ladder and the Blended/Observed/Device-based modes.
  • Send User-ID to GA4, Google developer docs. Basis for user_id as a reserved identifier that links users across sessions and devices.
  • Analytics Mania, GA4 User ID setup. Commentary on config tag values being static, handling values that arrive after login via event settings, and the double-placement standard in practice (secondary source, cross-checked against official docs above).

Starting from a GTM debug session where every user property showed undefined after login, this is a record of digging through GA4's scope model, cumulative dataLayer, identifiers, two-step setup, and analytics use. Two wrong turns and open questions (example numbers before real measurement, consent timing) left intact, so the next person doesn't stop at the same spot.

Found this useful? Share it

Latest posts

Related projects

Get new posts by email

Insights on marketing, analytics, and dev, delivered to your inbox.