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_nameand occurrence day - minimum and maximum
event_timestamp - unique
user_idanduser_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.