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:
- A few numbers for a deck: report downloads.
- The property’s daily history: the Data API and the script below.
- Raw events for SQL: BigQuery Export, linked now, since it never reaches back.
- Aggregated history inside BigQuery: the Data Transfer Service.
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).
- In Reports, open the report and set the date range, filters and comparisons you want in the file.
- Click Share this report, then Download File.
- 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:
- Up to 9 dimensions, 10 metrics and 4 date ranges per request.
- 10,000 rows by default and 250,000 at most per request, with
offsetto page androwCountfor the total. - Not every dimension works with every metric. An incompatible request fails, and
checkCompatibilitylists what fits. - Quotas per property, counted in tokens:
| 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):
dataLossFromOtherRow: rare values were rolled into an(other)row. Google treats a dimension with more than 500 values as high-cardinality (the (other) row).subjectToThresholding: rows were withheld so nobody can infer individuals, mostly demographics and search queries, and you cannot turn it off (thresholds).samplingMetadatas: the report read a sample of events, past 10 million a query on a standard property.dataTruncationReasons: part of the range was cut, by retention, the 36-month Google Ads window or another rule.
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:
- In Google Cloud, enable the Google Analytics Data API and create a service account with a JSON key.
- In GA4, under Admin, Property access management, add the service account’s
client_emailas a Viewer. - Copy the property ID from Admin, Property details. It is a number, not the
G-measurement ID. - Run the script from the month you installed GA4 to yesterday.
// 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:
- Daily
activeUserscannot be added up into monthly users: someone who visits on 10 days counts 10 times. Ask the API for theyearMonthdimension if you need users per month. - Dates are in the property’s reporting time zone, which the script prints. A tool that imports by UTC day shifts some of them.
keyEventscounts an event only from the day it was marked as a key event; marking it later “doesn’t change historic data” (API schema).
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):
- Daily: the previous day’s events, once a day. Standard properties are capped at 1 million events a day (360: 20 billion), and a property that keeps going over has its daily export paused, with missed days never reprocessed.
- Streaming: the current day within minutes, with no volume limit, at $0.05 per GB, which Google puts at about 600,000 events. It is best effort and leaves out the traffic source of new users.
- Fresh daily: Analytics 360 only.
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:
bq update --default_table_expiration 0 my-project:analytics_123456789FOR 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:
- Order. Plausible and Simple Analytics import up to your first native pageview, or up to today when there is none yet, so install their tracker before you import. Pirsch imports up to the day you created its dashboard. Fathom imports whatever range you pick, and importing the same range twice counts it twice.
- Matomo. An import always creates a new Matomo site that cannot be merged with one you already track, so import before you add Matomo’s tracker. The importer is built into Matomo Cloud and a free plugin On-Premise, and an import takes several days (Matomo).
- Pirsch’s import page still says Universal Analytics; its founder confirmed on Pirsch’s forum that it reads GA4 only.
- Billing. Swetrix does not count imported events against your plan. Mixpanel warns that a backfill “can have significant impact on your billing”, and PostHog’s import is free but needs a paid product analytics 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:
- Paths, segments and funnels over the imported days.
- Unique visitors over weeks and months. Plausible, Pirsch and Swetrix add up daily uniques, so someone who came on 10 days counts 10 times, and Matomo shows no uniques for periods longer than a day.
- Filters. Plausible allows only simple filters on imported data; Fathom and Pirsch allow none.
- Comparable numbers. Importers read the same Data API as the script, so
(other)rows, thresholds and Consent Mode modeling come along, and your new tool counts differently, so the line can jump at the switch in either direction. Why analytics numbers differ covers the reasons. - The time zone. Fathom notes that imported pageviews can land on a neighbouring day when your dashboard’s time zone differs from the property’s.
- Explorations, audiences, attribution reports and Google Ads links. None of them is data an importer can read.
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
- Event data retention is set to 14 months, not the default 2.
- The Data API script has run over the property’s whole life, and you have read the flags file.
- The standard reports you actually use are downloaded as CSV.
- Explorations you rely on are exported while their months are inside retention.
- If GA4 stays on your site for a while, BigQuery Export is linked, with billing on and no table expiry.
- Your new tool’s importer has run, in the order its docs ask.
- Google Ads conversions have a new home, or GA4 stays for ads only.
- The GA4 tag is gone from your pages, and the property is kept, not deleted.
