DocsMigrations

Migrations

Migrate from Google Analytics 4

3 min readReviewed July 2026

Google Analytics 4 is not a simple row-for-row event store in its reporting interface. For a controlled historical migration, use the linked BigQuery export rather than copying aggregate report totals. The export provides event-level tables, nested parameters, user identifiers, and source timestamps that you can map explicitly.

Start with the shared migration workflow.

Prepare the BigQuery export#

Follow Google’s official BigQuery Export setup before you need the history. GA4 does not backfill all historical data simply because a link is created later.

The GA4 BigQuery schema uses daily events_YYYYMMDD tables and may also use intraday tables. Daily tables can receive late updates for a documented period, so do not finalize the most recent dates prematurely.

Snapshot the query result or export it to durable, access-controlled files. Record:

  • GA4 property and BigQuery project
  • included tables and UTC processing time
  • row count and date range
  • query text or version
  • output checksum
  • whether late-arriving days are final

Design the target mapping#

GA4 field GraphJSON target Notes
event_name event Rename only through an intentional product taxonomy
event_timestamp request timestamp Convert microseconds to whole Unix seconds
user_id user_id Present only when your implementation set it
user_pseudo_id anonymous_id Browser/app instance identity, not a known user
event_params selected event fields Extract named values from the repeated record
ga_session_id parameter session_id Prefix with identity if uniqueness requires it
selected traffic/device fields explicit target fields Import only when the use case remains valid

GA4 contains many automatically collected and advertising-related fields. Perform a fresh privacy review rather than assuming that anything already in GA4 should enter GraphJSON.

Flatten parameters in SQL#

Export only the fields your target contract requires:

SELECT
  event_name,
  event_timestamp,
  user_id,
  user_pseudo_id,
  (
    SELECT value.int_value
    FROM UNNEST(event_params)
    WHERE key = 'ga_session_id'
  ) AS ga_session_id,
  (
    SELECT value.string_value
    FROM UNNEST(event_params)
    WHERE key = 'plan'
  ) AS plan,
  (
    SELECT COALESCE(
      value.double_value,
      value.float_value,
      CAST(value.int_value AS FLOAT64)
    )
    FROM UNNEST(event_params)
    WHERE key = 'value'
  ) AS value
FROM `my_project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260101' AND '20260630'

Adjust the table path, parameters, and dates. Inspect each parameter’s GA4 value type before coercing it; a parameter that alternates between string and numeric forms needs an explicit cleanup rule.

Transform an exported row#

function toGraphJSON(source) {
  const micros = Number(source.event_timestamp);
  if (!source.event_name || !Number.isFinite(micros)) {
    throw new Error("missing event_name or event_timestamp");
  }

  const anonymousId = source.user_pseudo_id || undefined;
  const sessionId =
    source.ga_session_id && anonymousId
      ? `${anonymousId}:${source.ga_session_id}`
      : undefined;

  return {
    timestamp: Math.floor(micros / 1_000_000),
    body: {
      event: source.event_name,
      user_id: source.user_id || undefined,
      anonymous_id: anonymousId,
      session_id: sessionId,
      plan: source.plan || undefined,
      value:
        typeof source.value === "number" ? source.value : undefined,
      schema_version: 1,
      migration_source: "ga4",
    },
  };
}

Remove undefined fields. If you need replay-safe deduplication, derive a deterministic event_id from a stable set of source fields and store the algorithm version. Test collision behavior before relying on it; GA4 does not necessarily provide one universal event-unique field for your export.

Do not recreate GA4 accidentally#

GA4 reports can apply modeled data, thresholding, identity choices, attribution, sessions, consent behavior, and reporting-time transformations. GraphJSON will preserve the events and definitions you implement; it will not silently reproduce those GA4 semantics.

Write explicit definitions for the metrics you intend to carry forward:

  • Does a session use GA4’s session parameter or an inactivity gap?
  • Is a user counted by user_id, user_pseudo_id, or a resolved combination?
  • Is revenue gross, net, refunded, and in which currency?
  • Does acquisition use first-touch, last-touch, or another model?

For business-critical values, reconcile against the application database or billing system in addition to GA4.

Reconcile the result#

Compare:

  • BigQuery export rows versus accepted and quarantined rows
  • counts by event_name and occurrence day
  • minimum and maximum event_timestamp
  • unique user_id and user_pseudo_id
  • session counts under the selected definition
  • sums of purchase value by currency
  • late-arriving rows for recent daily tables

Use raw BigQuery rows as the first comparison target. Differences from the GA4 interface are not necessarily import failures.

Cutover checklist#

  • The BigQuery link contains the required historical interval.
  • Recent daily tables are allowed to settle before final import.
  • Microsecond timestamps are converted to Unix seconds.
  • Nested parameters use explicit names and types.
  • Known users, pseudonymous users, and sessions are distinct concepts.
  • Automatic and advertising fields passed a new privacy review.
  • Important metrics have written definitions independent of the GA4 UI.
  • Raw-event and business-source reconciliation are complete.
Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support