invisit
All posts

By Miraç Berk13 min read

How to export Google Analytics 4 data and keep it after you switch

Every way to get your GA4 history out, from CSV to BigQuery, with row limits and quotas, a tested Data API script, and which tools import GA4.

A soft 3D analytics dashboard pouring rows of data into a stack of archive boxes, lit by violet dithered light and shadows.

To export your Google Analytics 4 data, pull the aggregated history through the Google Analytics Data API or download each report as CSV (up to 100,000 rows a file), and link BigQuery Export for raw events, which start only on the day you link it. GA4 keeps its standard aggregated reports with no stated time limit: the 14-month limit you may have read about covers only the event-level data behind explorations, and Universal Analytics data has been deleted since July 2024.

Every limit below comes from Google’s and each vendor’s own documentation, checked on November 3, 2026. To choose where to go next, see our Google Analytics alternatives; this post gets your data out first.

How long does GA4 keep data? The 14-month limit explained

GA4 keeps aggregated standard reports with no stated limit, and deletes the user- and event-level data behind explorations after 2 or 14 months on a standard property. Google’s data retention page says the setting “does not affect standard aggregated reports” and “only affects explorations and funnel reports”. As of November 2026:

Data Standard GA4 property Analytics 360
Aggregated standard reports No stated limit No stated limit
Event data in explorations and funnels 2 months by default, or 14 2, 14, 26, 38 or 50 months
User-level data and key events 2 or 14 months 2 or 14 months
Age, gender and interests 2 months, always 2 months, always
Google signals data 26 months at most 26 months at most
Google Ads data in reports A rolling 36 months A rolling 36 months
Event data once a property is Large or XL Cut to 2 months (Large) Cut to 2 months (XL)

Expired data is deleted once a month. Raising the setting applies, after 24 hours, to data you still have, so if your property sits on the default 2 months, switch it to 14 today under Admin, Data settings, Data retention.

The Data API reports this limit itself. A query that reaches into deleted event data comes back with a dataTruncationReasons entry of type DATA_TRUNCATION_TYPE_PROPERTY: “Data truncated because the query attempts to read event data prior to its retention date” (response metadata). The script below logs it.

What happened to Universal Analytics data?

Universal Analytics data is gone. Standard Universal Analytics properties stopped processing hits on July 1, 2023, and from the week of July 1, 2024 Google removed access to the interface and the API and deleted the data (Google). What survives is whatever you downloaded before then, plus Universal Analytics 360 data already exported to BigQuery.

No importer can bring it back: they all read Google’s API, and as Fathom’s docs put it, the data “is now gone from Google’s side” (Fathom).

Every way to export GA4 data, compared

GA4 has six ways out, and they differ most in how far back they reach. As of November 2026:

Method What it exports Row limit History it covers Cost Effort
Report download One standard report as CSV, Google Sheets or PDF 100,000 rows a file Any date range Free Minutes per report
Exploration export One exploration as Sheets, TSV, CSV or PDF Not documented 2 or 14 months; sampled past 10M events a query Free Minutes per exploration
Data Studio Any Data API field, chart by chart 750,000 rows a chart export Same as the Data API Free An hour
Data API Aggregated reports, up to 9 dimensions and 10 metrics each 250,000 rows a request, paged Standard reports’ full range; event data to retention Free within 200,000 tokens a day A script
BigQuery Data Transfer Service Data API reports as daily BigQuery tables, with backfill 9 dimensions and 10 metrics a custom report As far back as retention allows Free transfers; BigQuery storage An hour, with billing enabled
BigQuery Export Every raw event 1M events a day (daily); no limit (streaming) Only from the day you link it Sandbox free; streaming $0.05 per GB An hour to link, SQL to read

Sources: report export, explorations, sampling, Data Studio export, Data API quotas, runReport, GA4 transfers, BigQuery Export.

Pick by what you need to keep:

How to export GA4 reports to CSV or Google Sheets

Every GA4 report downloads as CSV or Google Sheets from Share this report, up to 100,000 rows a file, with the Viewer role on the property (Google).

  1. In Reports, open the report and set the date range, filters and comparisons you want in the file.
  2. Click Share this report, then Download File.
  3. Choose Download CSV, Export to Google Sheets or Download PDF.

The option is missing while you customize a report; save it and reopen it from the Reports menu. For more than 100,000 pages, export shorter date ranges.

Explorations export from Export data in the top right, as Google Sheets, TSV, CSV or PDF, with “all the data available in the selected visualization”, not only what is on screen (Explorations). Exploration data stops at your retention setting and is sampled past 10 million events a query on a standard property, so export the explorations you rely on before their months age out.

Data Studio, which Google renamed back from Looker Studio in April 2026 (release notes), reads GA4 through the same Data API and its quotas, without GA4 segments or comparisons (connector). Any chart exports up to 750,000 rows to CSV or Google Sheets.

How to export GA4 data with the Data API

The Google Analytics Data API returns GA4’s reports for any date range through runReport. Every direct GA4 importer reads it, and it pulls the property’s whole history in one run. Its limits, as of November 2026:

Core quota Standard property Analytics 360
Tokens per day 200,000 2,000,000
Tokens per hour 40,000 400,000
Tokens per project per hour 14,000 140,000
Concurrent requests 10 50

Most requests cost 10 tokens or fewer; long ranges and dimensions with many values, such as pagePath, cost more. Daily quotas reset at midnight Pacific time, and a request over quota fails with 429 RESOURCE_EXHAUSTED (quotas).

Every response also says when it is not the whole truth, in four fields (response metadata):

Google’s reporting surfaces comparison explains why one request reaches back years while another stops at your retention setting: for each request, Google picks an aggregated or an event-level table.

A script that exports daily totals and top pages to CSV

The script below writes three files: daily totals over your range, every page for each calendar month with the most viewed first, and a log of every response Google flagged. It uses Google’s Node.js client, @google-analytics/data, with a service account, as in Google’s quickstart:

  1. In Google Cloud, enable the Google Analytics Data API and create a service account with a JSON key.
  2. In GA4, under Admin, Property access management, add the service account’s client_email as a Viewer.
  3. Copy the property ID from Admin, Property details. It is a number, not the G- measurement ID.
  4. Run the script from the month you installed GA4 to yesterday.
export-ga4.mts
// Archive a GA4 property through the Data API: daily totals and each month's pages as CSV, plus
// every response Google sampled, thresholded, truncated or rolled into (other). Node 22.18+:
// npm install @google-analytics/data
// GOOGLE_APPLICATION_CREDENTIALS=key.json node export-ga4.mts 123456789 2024-01-01 2026-10-31
import { writeFileSync } from 'node:fs'
import { BetaAnalyticsDataClient } from '@google-analytics/data'

const [property, from, to] = process.argv.slice(2)
if (!property || !from || !to)
  throw new Error('usage: node export-ga4.mts <property id> <from> <to>')

const client = new BetaAnalyticsDataClient()
const flags: string[][] = []
let timeZone = ''

const csv = (header: string[], rows: string[][]) =>
  [header, ...rows]
    .map((row) => row.map((v) => (/[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v)).join(','))
    .join('\n') + '\n'

// Every row of one report. A request returns at most 250,000 rows, so page with offset, ordered
// by the dimensions so that no row moves between pages.
async function report(
  label: string,
  start: string,
  end: string,
  dims: string[],
  metrics: string[],
) {
  const rows: string[][] = []
  while (true) {
    const [res] = await client.runReport({
      property: `properties/${property}`,
      dateRanges: [{ startDate: start, endDate: end }],
      dimensions: dims.map((name) => ({ name })),
      metrics: metrics.map((name) => ({ name })),
      orderBys: dims.map((dimensionName) => ({ dimension: { dimensionName } })),
      limit: 250_000,
      offset: rows.length,
      returnPropertyQuota: true,
    })
    // Google says when a response is not the whole truth; keep that next to the numbers.
    const meta = rows.length === 0 ? res.metadata : null
    timeZone ||= meta?.timeZone ?? ''
    if (meta?.dataLossFromOtherRow)
      flags.push([label, 'other_row', 'rare rows merged into (other)'])
    if (meta?.subjectToThresholding) flags.push([label, 'thresholded', 'small rows withheld'])
    for (const s of meta?.samplingMetadatas ?? [])
      flags.push([label, 'sampled', `${s.samplesReadCount} of ${s.samplingSpaceSize} events read`])
    for (const t of meta?.dataTruncationReasons ?? [])
      flags.push([label, `truncated ${t.dataTruncationType}`, t.dataTruncationMessage ?? ''])

    // A loop, not rows.push(...page): 250,000 arguments overflow the call stack.
    const page = res.rows ?? []
    for (const r of page)
      rows.push([...(r.dimensionValues ?? []), ...(r.metricValues ?? [])].map((v) => v.value ?? ''))
    const left = res.propertyQuota?.tokensPerDay?.remaining
    console.log(`${label}: ${rows.length} of ${res.rowCount ?? 0} rows, ${left} tokens left today`)
    if (page.length === 0 || rows.length >= (res.rowCount ?? 0)) return rows
  }
}

// Daily totals over the whole range, with GA4's YYYYMMDD dates written as YYYY-MM-DD.
const totals = ['activeUsers', 'newUsers', 'sessions', 'engagedSessions', 'screenPageViews']
totals.push('eventCount', 'keyEvents', 'userEngagementDuration')
const daily = await report('daily', from, to, ['date'], totals)
for (const row of daily) row[0] = row[0]!.replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')
writeFileSync(`ga4_daily_${from}_${to}.csv`, csv(['date', ...totals], daily))

// Pages, one request per calendar month, most viewed first.
const pages: string[][] = []
const metrics = ['screenPageViews', 'activeUsers', 'userEngagementDuration']
const month = new Date(`${from.slice(0, 7)}-01`)
while (month <= new Date(to)) {
  const first = month.toISOString().slice(0, 10)
  month.setUTCMonth(month.getUTCMonth() + 1)
  const last = new Date(month.getTime() - 86_400_000).toISOString().slice(0, 10)
  const ym = first.slice(0, 7)
  const start = first < from ? from : first
  const end = last > to ? to : last
  const rows = await report(`pages ${ym}`, start, end, ['hostName', 'pagePath'], metrics)
  rows.sort((a, b) => Number(b[2]) - Number(a[2]))
  for (const row of rows) pages.push([ym, ...row])
}
const pageHeader = ['month', 'hostname', 'page', 'views', 'active_users', 'engagement_seconds']
writeFileSync(`ga4_pages_${from}_${to}.csv`, csv(pageHeader, pages))

writeFileSync(`ga4_flags_${from}_${to}.csv`, csv(['report', 'flag', 'detail'], flags))
console.log(`Dates are in ${timeZone}, the property's time zone. ${flags.length} flags.`)

When you read the files:

How to set up BigQuery export for GA4, and why it will not save your past

BigQuery Export sends every raw GA4 event to your own BigQuery project, but only from the day you link it: “GA4 BigQuery Export starts from the day it is set up and there is no backfilling” (Google Cloud). Link it under Admin, Product links, BigQuery links, with the Editor role on the property and Owner on the Cloud project. Data starts arriving within 24 hours (setup).

GA4 offers three schedules (BigQuery Export):

The free BigQuery sandbox is not an archive. Every table in the sandbox expires after 60 days, storage has a lifetime limit of 10 GiB that is “not refunded upon data deletion”, and streaming does not work there (sandbox). At Google’s 600,000 events a gigabyte, 10 GiB is roughly 6 million events, in total. To keep the export, enable billing on the project, then clear the expiry on the dataset and on every table that already has one:

Terminal
bq update --default_table_expiration 0 my-project:analytics_123456789
Clear the expiry on existing GA4 tables
FOR t IN (
  SELECT table_name FROM `my-project.analytics_123456789.INFORMATION_SCHEMA.TABLES`
  WHERE table_name LIKE 'events%'
)
DO
  EXECUTE IMMEDIATE FORMAT(
    'ALTER TABLE `my-project.analytics_123456789.%s` SET OPTIONS (expiration_timestamp = NULL)',
    t.table_name
  );
END FOR;

Both follow Google’s bq reference and DDL reference; we have no GA4 dataset to run them on. With billing on, the first 10 GiB of storage and the first 1 TiB of queries each month are free, and on-demand queries then cost $6.25 per TiB (pricing).

The raw export has no Google signals data, modeling or data-driven attribution, and no (other) rows or sampling either (reporting surfaces). A day that has been exported cannot be exported again.

For the months before the link, the BigQuery Data Transfer Service’s GA4 connector loads Data API reports into BigQuery and backfills them “as far back as the data retention policy” allows. Transfers are free but need billing, since the sandbox does not run the Data Transfer Service. Custom reports take 9 dimensions and 10 metrics and no custom dimensions, user and session totals may not match GA4, and you get aggregates, never the raw events you did not export.

Which analytics tools can import GA4 history?

Six web analytics tools import GA4 history directly through the Data API: Plausible, Fathom, Simple Analytics, Pirsch, Swetrix and Matomo. Mixpanel, PostHog and Amplitude import raw GA4 events from BigQuery Export, so they only get the days since you linked it. As of November 2026, from each tool’s own docs:

Tool What comes across What stays behind
Plausible Daily visitors, pageviews, sources and campaigns, pages, entry pages, locations, devices, browsers, OS and custom events, from up to 5 properties per site Revenue, exit pages, scroll depth, hourly data, browser versions and a separate UTM source
Fathom Daily traffic data as far back as the property goes Events; filtering and hourly views of imported data
Simple Analytics page_view events by hour, with page, referrer, device, country, browser and OS, for the hostnames you pick Every other event
Pirsch One data point per dimension per day Filtering and hourly views of imported data, exit pages; imported data cannot be exported again
Swetrix Pageviews by hour with host, page, device, browser, OS, country, region, city and UTM tags, plus custom events Language, browser and OS versions, session duration
Matomo Aggregated reports, into a new Matomo site Visitor Log, segments and custom reports on imported days; multi-day uniques; funnels; goal names and revenue
Mixpanel, PostHog, Amplitude Raw events from your BigQuery Export Every day before you linked BigQuery

Sources: Plausible, Fathom, Simple Analytics, Pirsch, Swetrix, Matomo, Mixpanel, PostHog, Amplitude.

The details that change your plan:

Umami, Rybbit and DataFast have no GA4 importer. Umami Cloud imports CSV files by request from its Pro plan, handled by its team, and its docs do not name GA4 (Umami). Rybbit imports Plausible, Umami and Simple Analytics (Rybbit), and DataFast imports Plausible. Leaving Matomo rather than GA4 is a different export; our Matomo post has its own script.

invisit, the tool we build, cannot import history from GA4 or any other tool either. If you move to invisit, keep the CSV files from the script as your GA4 archive and run both trackers side by side for a few weeks (invisit vs GA4). Leaving invisit later is simpler: Export raw events in site settings gives you a gzipped NDJSON file of the site’s raw events and funnels, with no API quota in the way.

What do you lose when you import GA4 data?

Every GA4 importer brings over totals, not visitors, so anything that needs individual visits stays behind, whichever tool you pick:

Keep the property after you remove the tag. A standard property costs nothing and its aggregated reports stay readable. Deleting it moves it to the Trash, and 35 days later it is gone for good (Google).

Checklist before you stop using GA4