Why Your GA4 Variables Show ‘Unset’ (And How to Fix It)

You’ve spent hours configuring Google Tag Manager. Every variable is mapped. Every trigger fires perfectly in Preview mode. You test, retest, and finally hit publish with confidence.

The next morning, you open GA4 reports.

40% of your custom event parameters show “(unset)”.

Welcome to one of the most frustrating debugging experiences in web analytics: the invisible timing issue that only appears in production.

I’ve spent the last two weeks debugging this exact problem for a client running a multi-country e-commerce platform. Their GTM setup was textbook perfect. Their dataLayer implementation followed all best practices. And yet, hundreds of thousands of events were landing in GA4 with missing parameters.

The culprit? Race conditions between dataLayer initialization and GTM container execution.

In this article, I’ll walk you through why this happens, how to diagnose it properly, and most importantly, how to fix it permanently. This isn’t theory—these are battle-tested solutions from real production environments.

What “(unset)” Actually Means in GA4

Before we dive into fixes, let’s clarify what we’re dealing with.

When GA4 displays “(unset)” in your reports, it means the variable was read but returned undefined, null, or an empty string at the exact moment the tag fired.

This is different from “(not set)”, which typically indicates the parameter wasn’t included in the event at all.

Why does GA4 show this instead of just dropping the parameter?

Because GA4 wants to preserve the event structure. If you’ve configured an event parameter in your schema, GA4 will report on it even if the value is missing. This actually helps you spot tracking issues—you can see that events ARE firing, but data isn’t making it through.

The real-world impact is brutal:

  • Your user segmentation breaks (can’t segment by undefined user_type)
  • E-commerce attribution falls apart (transaction_id showing unset)
  • Custom dimensions become useless (40% of events with missing context)
  • Your dashboards fill with “(unset)” rows that nobody knows how to interpret
  • Stakeholders lose trust in the data (“why is everything broken?”)

The worst part? This often goes unnoticed for weeks or months. Your tracking appears to work—events are firing, hit counts look right. But you’re silently losing granularity and accuracy.

The 3 Root Causes I See Every Week

After debugging dozens of these cases, I’ve identified three primary causes. Let’s break them down with real examples.

Cause #1: Race Condition Between dataLayer and GTM Container

This is the most common culprit, and it’s insidious because it works perfectly in testing environments.

Here’s what happens:

html

<!DOCTYPE html>
<html>
<head>
  <!-- Google Tag Manager -->
  <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
  'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
  })(window,document,'script','dataLayer','GTM-XXXXXX');</script>
  <!-- End Google Tag Manager -->
</head>
<body>
  
  <!-- Your dataLayer init happens here, AFTER GTM loads -->
  <script>
    window.dataLayer = window.dataLayer || [];
    
    // This data comes from an API call
    fetch('/api/user-data')
      .then(response => response.json())
      .then(data => {
        window.dataLayer.push({
          'userID': data.user_id,
          'userType': data.user_type,
          'membershipLevel': data.membership
        });
      });
  </script>
  
</body>
</html>

What’s wrong here?

The GTM container loads in the <head>. It immediately starts executing tags. Your “All Pages” trigger fires a GA4 page_view event.

At that exact moment, the dataLayer exists (because GTM creates it if it doesn’t), but it’s empty. The API call hasn’t returned yet. Your userID, userType, and membershipLevel variables read as undefined.

GTM sends the event to GA4 with unset parameters.

300 milliseconds later, your API responds. The dataLayer gets populated. But it’s too late—the page_view event already fired.

Why doesn’t this happen in Preview mode?

Because when you’re testing locally or on fast connections:

  • Your local server responds in 10ms, not 300ms
  • Browser cache makes subsequent loads instant
  • No network latency, no packet loss
  • The timing “just works” by accident

But in production, with real users on 3G connections, CDN latency, and API calls that take 500ms+? The race condition becomes obvious.

Real example from last week:

A client’s GTM container is hosted on Google’s CDN (fast). Their dataLayer initialization script is hosted on their own origin (slower). On desktop with fiber internet, both load almost simultaneously. On mobile 3G, the GTM container consistently loads 400-800ms before their script executes.

Result: 60% of mobile users had unset variables. Desktop users? Perfectly fine.

Cause #2: Reading Variables at the Wrong Event Timing

Even if your dataLayer is initialized before GTM loads, you can still hit timing issues based on WHEN variables are read during the event lifecycle.

The key concept: dataLayer variables can be scoped to specific events or persist across events.

Consider this sequence:

javascript

// Page loads
window.dataLayer = window.dataLayer || [];

// Initial page data (persists)
window.dataLayer.push({
  'pageType': 'product',
  'pageCategory': 'electronics'
});

// GTM fires page_view event
// ✓ pageType = 'product'
// ✓ pageCategory = 'electronics'

// User adds item to cart (event-specific data)
window.dataLayer.push({
  'event': 'add_to_cart',
  'ecommerce': {
    'items': [{
      'item_name': 'Smartphone X',
      'price': 699
    }]
  }
});

// Later, user views another page (SPA navigation)
// ✗ ecommerce data STILL PERSISTS in dataLayer
// If not cleared, next page_view includes wrong ecommerce data

The problem:

If you’re using Data Layer Variable Version 1 in GTM, variables persist until explicitly cleared. If you’re using Version 2, variables are scoped to events by default but can still cause issues if you’re reading them at the wrong time.

Common scenario I see:

E-commerce sites that push transaction data to the dataLayer AFTER the page_view event fires. They have this sequence:

  1. Page loads → page_view fires (transaction_id = unset)
  2. 50ms later → CMS populates transaction data
  3. Custom event fires → transaction_id now populated correctly

The page_view event has already gone to GA4 with unset transaction_id. The custom event works fine. So you get a split-brain situation where some events have the data and others don’t.

Version 1 vs Version 2 Data Layer Variables:

In GTM, when you create a Data Layer Variable, you choose the version:

  • Version 1: Reads the LATEST value from the dataLayer, even if it was pushed after the event fired. Can cause unexpected behavior.
  • Version 2: Reads the value FROM THE SPECIFIC EVENT only. More predictable but requires data to be pushed BEFORE or WITH the event.

Most modern implementations should use Version 2, but I still see plenty of Version 1 variables in production that cause these issues.

Cause #3: dataLayer Not Cleared Between Pushes

This is especially problematic for Single Page Applications (SPAs) or sites with dynamic content loading.

The scenario:

javascript

// User views Product A
window.dataLayer.push({
  'event': 'product_view',
  'productID': '12345',
  'productName': 'Smartphone X',
  'productPrice': 699
});

// User navigates to Product B (SPA, no page reload)
window.dataLayer.push({
  'event': 'product_view',
  'productID': '67890',
  'productName': 'Laptop Y',
  'productPrice': 1299
});

// Problem: productName from Product A still exists in dataLayer!
// If not explicitly cleared, some tags might read stale data

Why does this matter for unset values?

Because if your implementation EXPECTS a value to be cleared but it’s not, you might set up logic like:

javascript

// Attempt to clear by setting to empty string
window.dataLayer.push({
  'productID': '',
  'productName': '',
  'productPrice': ''
});

But GTM might interpret '' (empty string) differently than undefined or null. Some tags might send empty string as “(unset)”, others might skip the parameter entirely.

The correct way to clear:

javascript

window.dataLayer.push({
  'productID': undefined,
  'productName': undefined,
  'productPrice': undefined
});

Or use GTM’s this.reset() method in certain advanced scenarios.

Real-world example:

A travel booking site I worked with had a multi-step form. Each step pushed form data to the dataLayer. They assumed that pushing NEW data would overwrite OLD data.

It didn’t.

Step 3 of the form still had fields from Step 1 in the dataLayer, causing duplicate or conflicting data to be sent. Some variables showed correct values, others showed stale data, and some showed unset because validation logic failed when it saw conflicting values.

My 5-Step Debugging Checklist

When I’m called in to debug unset variables, I follow this exact process. It catches 95% of issues.

Step 1: Verify dataLayer Position in Source Code

What to check:

Open your page source (View > Developer > View Source in most browsers) and find where the dataLayer is initialized.

The correct order MUST be:

html

<!-- 1. dataLayer initialization FIRST -->
<script>
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    'userID': '12345',
    'pageType': 'product'
  });
</script>

<!-- 2. GTM container SECOND -->
<script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXXXX');</script>

Common mistakes:

WordPress sites using plugins: Many GTM plugins inject the container in the <head> automatically, but your dataLayer initialization might be in the theme’s footer or in a separate plugin that loads later.

Shopify themes: GTM snippet is in the theme’s <head>, but Shopify’s Liquid variables that populate the dataLayer are rendered in the <body>. By the time Liquid executes, GTM has already fired its tags.

Custom builds with async loading: Developers love to async-load everything for performance. But if your dataLayer script is loaded async AFTER the GTM container script, you’ve created a race condition.

How to fix:

Move your dataLayer initialization to the earliest possible point in the <head>, BEFORE the GTM snippet. Make it synchronous (not async) to guarantee execution order.

Step 2: Use GTM Preview with Network Throttling

GTM Preview mode is great, but it lies to you if you don’t simulate real network conditions.

How to enable network throttling in Chrome:

  1. Open Chrome DevTools (F12 or Cmd+Opt+I)
  2. Go to the Network tab
  3. Look for the throttling dropdown (usually says “No throttling”)
  4. Select “Fast 3G” or “Slow 3G”
  5. Reload the page with GTM Preview mode active

What to look for:

Watch the Timeline in GTM Preview. You should see events firing in order:

  • Container Loaded
  • Page View
  • (Your custom events)

Now look at the Variables tab for each event. Check if your custom Data Layer Variables show values or undefined.

With throttling enabled, you’ll often see:

  • Page View fires at T+0ms → variables are undefined
  • A few hundred milliseconds later, variables populate
  • But the Page View already sent to GA4

This exposes the race condition that fast connections hide.

Pro tip:

Test on an actual mobile device with 3G enabled, not just desktop throttling. Real-world conditions include:

  • Packet loss
  • Connection dropping and reconnecting
  • DNS lookup delays
  • TLS handshake latency

Desktop throttling simulates bandwidth limits but not these other factors.

Step 3: Inspect dataLayer State in Real-Time

Method 1: Console Commands

Open Chrome DevTools console and type:

javascript

console.log(window.dataLayer);

You’ll see an array of all dataLayer pushes. Expand each one to see what data was pushed at what time.

Look for:

  • Is the dataLayer initialized before GTM?
  • Are your expected variables in the initial push?
  • Are variables being pushed AFTER events fire?

Method 2: dataslayer Chrome Extension

Install the dataslayer extension (free). It gives you a clean UI showing:

  • All dataLayer pushes in chronological order
  • Current state of all variables
  • GTM tags that fired and what data they used

Method 3: Add Timestamps

Modify your dataLayer pushes temporarily to include timestamps:

javascript

window.dataLayer.push({
  'event': 'add_to_cart',
  'productID': '12345',
  '_timestamp': Date.now()
});

Then check in GTM Preview which events have timestamps and when they occurred relative to tag fires.

Step 4: Check Variable Configuration in GTM

Open GTM and inspect your Data Layer Variables.

Key settings to verify:

1. Data Layer Variable Version:

  • Variables > Your Data Layer Variable > Data Layer Version
  • Should almost always be “Version 2” for modern implementations
  • If Version 1, consider switching (but test thoroughly first)

2. Data Layer Variable Name:

  • Must EXACTLY match the key in your dataLayer push
  • Case-sensitive: userIDuseridUserID
  • Dot notation for nested values: ecommerce.transaction_id

3. Default Value:

  • Should typically be left blank
  • Setting a default value can MASK issues (you won’t see unset, but you also won’t know when data is missing)
  • Only use defaults if you have a specific fallback strategy

4. Format Value:

Some variables have “Format Value” options (lowercase, uppercase, etc.). Verify these aren’t causing issues:

  • Converting numbers to strings unintentionally
  • Trimming whitespace that’s actually significant
  • Regex replacements that fail and return empty string

Step 5: Test with Real User Conditions

Real devices matter:

  • Test on actual iOS Safari (not just desktop Safari)
  • Test on actual Android Chrome (not just desktop Chrome mobile emulation)
  • Test on slow WiFi, not just throttled desktop

Different browsers have different timing quirks:

  • Safari has different script execution timing than Chrome
  • Firefox handles async script loading differently
  • Edge has its own peculiarities

Ad blockers and privacy tools:

  • Test with uBlock Origin enabled
  • Test with Privacy Badger enabled
  • Test with Brave browser (blocks trackers by default)

Sometimes GTM itself gets blocked, so your dataLayer is fine but no tags fire at all. This looks like unset variables in reports because NO data makes it to GA4.

Check from different geographic regions:

If your CDN serves GTM from different edge nodes based on user location, test from:

  • North America
  • Europe
  • Asia-Pacific

CDN latency varies by region, which can expose timing issues in some markets but not others.

The Fixes That Actually Work

Now let’s get to solutions. These are ordered from simplest to most robust.

Fix #1: Use Custom Events, Not Auto-Events

The problem with automatic pageview triggers:

GTM’s built-in “All Pages” trigger fires immediately when the container loads. If your dataLayer isn’t ready yet, too bad.

The solution: Explicit custom events

javascript

// Don't rely on automatic pageview
// Instead, push a custom event when data is ready

window.dataLayer = window.dataLayer || [];

// Initialize with data
window.dataLayer.push({
  'userID': '12345',
  'userType': 'premium',
  'pageCategory': 'product'
});

// THEN fire the event
window.dataLayer.push({
  'event': 'dataReady'
});

In GTM, change your Page View tag trigger from “All Pages” to “Custom Event: dataReady”.

Why this works:

You control exactly when the event fires. No race conditions. The data is guaranteed to be in the dataLayer before the event triggers tags.

Bonus: You can stack this for different data types

javascript

// Core page data ready
window.dataLayer.push({'event': 'pageDataReady'});

// User data ready (might load from API)
fetch('/api/user')
  .then(res => res.json())
  .then(data => {
    window.dataLayer.push({
      'userID': data.id,
      'userType': data.type
    });
    window.dataLayer.push({'event': 'userDataReady'});
  });

// Product data ready (might load from CMS)
window.dataLayer.push({'event': 'productDataReady'});

Then in GTM, configure different tags to fire on different custom events based on what data they need.

Fix #2: Clear dataLayer Values Explicitly

For SPAs and dynamic content, always clear old values:

javascript

// User views Product A
window.dataLayer.push({
  'event': 'product_view',
  'productID': '12345',
  'productName': 'Smartphone X',
  'productPrice': 699
});

// User navigates to Product B
// FIRST: Clear old product data
window.dataLayer.push({
  'productID': undefined,
  'productName': undefined,
  'productPrice': undefined
});

// THEN: Push new product data
window.dataLayer.push({
  'event': 'product_view',
  'productID': '67890',
  'productName': 'Laptop Y',
  'productPrice': 1299
});

Why undefined and not null or ''?

  • undefined: GTM treats this as “variable doesn’t exist”, won’t send the parameter
  • null: GTM might send “null” as a string value
  • '' (empty string): GTM might send this as an empty value, showing up as “(unset)” in GA4

Alternative: Use GTM’s reset method

In advanced scenarios, you can use:

javascript

window.dataLayer.push(function() {
  this.reset();
});
```

This clears the entire dataLayer state. Use cautiously—it wipes EVERYTHING, including GTM's internal state.

#### Fix #3: Add "Wait For" Logic in GTM Triggers

**For triggers that need specific data, add conditions:**

In GTM:

1. Create a trigger (e.g., Custom Event: add_to_cart)
2. Add a condition: `{{DLV - productID}}` does not equal `undefined`
3. Optionally, add a timeout: Fire anyway after 3000ms if data never arrives

**Example configuration:**
```
Trigger Type: Custom Event
Event name: add_to_cart

This trigger fires on: Some Custom Events

Conditions:
- Event equals add_to_cart
- {{DLV - productID}} does not equal undefined
- {{DLV - productName}} does not equal undefined

(Optional) Fire on timeout: 3000ms

Why this works:

The tag won’t fire until the required variables are populated. If they never populate, the timeout ensures the tag eventually fires (even with unset values) so you don’t lose the event entirely.

Caveat:

Overusing this can make your GTM setup complex and fragile. Prefer fixing the root cause (dataLayer timing) over band-aid trigger conditions.

Fix #4: Centralize dataLayer Initialization in <head>

The gold standard approach:

html

<!DOCTYPE html>
<html>
<head>
  <!-- Step 1: Initialize dataLayer with ALL critical data -->
  <script>
    window.dataLayer = window.dataLayer || [];
    
    // Inline critical data (from server-side rendering)
    window.dataLayer.push({
      'userID': '<?php echo $user_id; ?>',
      'userType': '<?php echo $user_type; ?>',
      'pageType': 'product',
      'pageCategory': 'electronics'
    });
  </script>
  
  <!-- Step 2: Load GTM container -->
  <script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXXXX');</script>
</head>
<body>
  <!-- Page content -->
</body>
</html>

Key principles:

  • Render critical dataLayer values server-side (PHP, Node.js, Python, whatever your backend is)
  • This guarantees the data exists BEFORE GTM loads
  • No async API calls, no race conditions
  • For data that MUST be async (third-party APIs), use custom events as in Fix #1

When you can’t do server-side rendering:

If you’re on a static site or can’t control server-side rendering:

html

<head>
  <script>
    window.dataLayer = window.dataLayer || [];
    
    // Pull from localStorage or cookies (synchronous)
    var userID = localStorage.getItem('user_id');
    var userType = localStorage.getItem('user_type');
    
    if (userID && userType) {
      window.dataLayer.push({
        'userID': userID,
        'userType': userType
      });
    }
    
    // Fire custom event when data is ready
    window.dataLayer.push({'event': 'userDataReady'});
  </script>
  
  <script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXXXX');</script>
</head>

This pulls data from localStorage (synchronous operation) before GTM loads.

Advanced: Multi-Container Nightmares

I’m going to be blunt: most sites don’t need multiple GTM containers.

But if you DO have multiple containers (common in large enterprises), timing issues multiply exponentially.

Why multi-container makes this worse:

Each container loads independently. Each reads the dataLayer at ITS OWN execution time.

Example disaster scenario:

  • Container 1 (Core Tags) loads at T+0ms → dataLayer is empty → sends events with unset values
  • Container 2 (Marketing Tags) loads at T+200ms → dataLayer now populated → sends events with correct values
  • Container 3 (Analytics Tags) loads at T+400ms → same data as Container 2

Now you have the same user, same session, but different containers sending conflicting data to GA4.

The fix (if you must use multiple containers):

Centralize your dataLayer initialization in a SINGLE script that loads before ALL containers:

html

<head>
  <!-- Load dataLayer init script FIRST -->
  <script src="/js/datalayer-init.js"></script>
  
  <!-- THEN load all containers -->
  <script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-CORE');</script>
  <script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-MARKETING');</script>
  <script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-ANALYTICS');</script>
</head>

In /js/datalayer-init.js:

javascript

window.dataLayer = window.dataLayer || [];

// Populate all required data
window.dataLayer.push({
  'userID': getUserID(),      // Your function
  'userType': getUserType(),  // Your function
  'pageType': getPageType(),  // Your function
  // ... all other critical variables
});

// Fire event when ready
window.dataLayer.push({'event': 'dataLayerReady'});

Then in ALL containers:

Configure your tags to fire on the custom event dataLayerReady instead of page load.

This ensures all containers wait for the centralized dataLayer to be fully populated before executing tags.

My controversial opinion:

If you find yourself fighting multi-container timing issues for more than a day, seriously reconsider whether you need multiple containers.

In 90% of cases, proper use of:

  • Workspaces (for team collaboration)
  • Folders (for organization)
  • Naming conventions (for clarity)
  • User permissions (for governance)

…will solve the same problems that people think require multiple containers, without the debugging nightmare.

The Definitive Debugging Checklist

Print this out and tape it to your monitor:

Pre-Flight Checks:

  • dataLayer initialized in <head> BEFORE GTM container snippet
  • All critical variables populated synchronously (server-side or localStorage)
  • GTM container snippet is NOT async/defer

GTM Configuration:

  • Data Layer Variables use Version 2 (unless you have a specific reason for V1)
  • Variable names EXACTLY match dataLayer keys (case-sensitive)
  • No default values set (unless intentional fallback strategy)
  • Triggers use custom events, not just “All Pages”

Testing Checklist:

  • Test in GTM Preview mode with network throttling (Fast 3G minimum)
  • Inspect window.dataLayer in console—verify data exists before events fire
  • Test on real mobile devices (iOS Safari, Android Chrome)
  • Test with ad blockers enabled
  • Review GA4 DebugView in real-time while testing
  • Check production reports for (unset) spikes after deployment

Production Monitoring:

  • Set up GA4 custom alerts for (unset) parameter spikes
  • Monitor dataLayer errors in console (set up error tracking)
  • Regularly audit reports for unset values
  • Document known timing constraints in your implementation

Tools I Use Daily

For debugging dataLayer:

  • dataslayer (Chrome extension) – Clean UI for inspecting dataLayer state
  • GTM Preview mode – Essential, but always enable network throttling
  • Chrome DevTools Network tab – See exact timing of script loads
  • GA4 DebugView – Real-time validation of events and parameters

For production monitoring:

  • GA4 custom alerts – Email me when (unset) values spike above threshold
  • Sentry or LogRocket – Catch JavaScript errors that might break dataLayer
  • Looker Studio – Dashboard showing % of events with unset parameters over time

For advanced analysis:

  • BigQuery – Export GA4 data and query for unset patterns
  • SQL queries – Identify which events/parameters have highest unset rates

When to Give Up and Refactor

Sometimes, the cleanest solution is to admit your implementation is fundamentally broken and start over.

Signs it’s time to refactor:

  • You’re fighting timing issues for more than 2 days with no progress
  • Multiple containers with inter-dependencies that nobody fully understands
  • dataLayer populated by 5+ different scripts from different teams
  • Over 30% of events consistently show unset values despite fixes
  • Your GTM container has 200+ tags and nobody knows what half of them do

The refactor approach:

  1. Audit current state: Export all tags, triggers, variables from GTM. Document what actually needs to fire.
  2. Design new dataLayer schema: Decide what variables you ACTUALLY need. Don’t carry forward legacy cruft.
  3. Server-side render critical data: Work with developers to populate dataLayer on the server before page renders.
  4. Single source of truth: One script, one place, initializes the dataLayer. No exceptions.
  5. Custom events for async data: If data must load async, use explicit custom events to signal readiness.
  6. Clean up GTM: Remove unused tags. Consolidate redundant variables. Simplify triggers.
  7. Test ruthlessly: Throttled networks, real devices, ad blockers, edge cases.
  8. Document everything: Future you (and future team members) will thank you.

Conclusion

Unset variables in GA4 are almost always a timing issue, not a configuration issue.

Your GTM setup can be perfect. Your dataLayer schema can be well-designed. But if the dataLayer isn’t fully populated BEFORE tags fire, you’ll get unset values.

The fixes are straightforward:

  1. Initialize dataLayer BEFORE GTM container loads
  2. Populate critical data synchronously (server-side or from cookies/localStorage)
  3. Use custom events for async data, don’t rely on automatic pageview triggers
  4. Test with network throttling to expose race conditions
  5. Consider whether you really need multiple GTM containers

Remember: the goal isn’t elegant architecture. The goal is accurate data.

If your current implementation is fighting you, simplify. Move dataLayer initialization earlier. Use custom events. Test on slow networks.

And if all else fails, refactor. Sometimes the cleanest path forward is admitting the current implementation is broken and building it right from scratch.

Want to dive deeper into tracking implementations? Check out my other articles on fasilytics.fr where I break down Firebase mobile tracking, server-side GTM setups, and debugging Tag Commander architectures.

Got questions or want to share your own debugging war stories? Hit me up on LinkedIn or leave a comment below.


Sources & Further Reading

Laurent Fidahoussen
Laurent Fidahoussen

Ads & Tracking & Analytics & Dataviz for better Data Marketing and boost digital performance

25 years in IT, 10+ in digital data projects — I connect the dots between tech, analytics, reporting & media (not a pure Ads expert—but I’ll make your campaigns work for you)
- Finding it hard to launch, track, or measure your digital campaigns?
- Not sure if your marketing budget is working—or how your audiences behave?
- Messy tracking makes reporting a nightmare, and fast decisions impossible?
- Still wrestling with Excel to build dashboards, without real actionable insights?

I can help you:
- Launch and manage ad campaigns (Google, Meta, LinkedIn…)
- Set up robust, clean tracking—so you know what every euro gives you back
- Build and optimize events: visits, product views, carts, checkout, purchases, abandons
- Create dashboards and analytics tools that turn your data into real growth drivers
- Streamline reporting and visualization for simple, fast decisions

Curious? Let’s connect—my promise: clear, no jargon, just better results.

Stack:
Ads (Google Ads, Meta Ads, LinkedIn Ads) | Analytics (Adobe Analytics, GA4, GTM client & server-side) | Dataviz (Looker Studio, Power BI, Python/Jupyter)

Articles: 36