Build a small Python report with the GA4 Data API: fixed scope, full pagination, and checks for sampling, missing days and (other) rows before saving a CSV.
5 min readBranislav Mateas
Copy post as Markdown
On this page
A GA4 Data API call can succeed and still give you a poor report. The first page may hold only part of the rows, a filter may hide an (other) row, and a sampled result looks like any other table. So a useful reporting script does more than fetch data. It refuses to save a result that it cannot account for.
This guide builds one small Python script that exports a daily channel report to CSV. It assumes you have chosen the Data API over BigQuery for this question, as covered in GA4 Data API or BigQuery, and that you can read an API response, as in Calling an API.
The script and its output are hypothetical. The field names were checked against Google's documentation and client library source, but the script has not been run against a real property. Test it on your own property before you rely on it.
Fix the report before you write code
Write the report's contract in one sentence: one row per day and default channel group, with sessions and key events, for a closed date range.
That sentence decides a lot. The API schema names these fields date, sessionDefaultChannelGroup, sessions and keyEvents. The date range should be fixed and old enough to have settled, because Google says results can change between retrievals while processing continues. Avoid today in a report that someone will compare later.
Keep the daily rows as they are. If you also need a total for the whole period, request it separately instead of adding up days. Unique counts such as users are approximations, and they do not add up across rows.
Set up access without a key in your code
The Python quickstart has three parts: enable the Data API in a Google Cloud project, give your account access to the GA4 property, and create local credentials. The credentials command is:
The client finds the credentials itself, so no secret appears in the script. An unattended job usually runs under a service account instead. That account also needs access to the property, and its key file must stay out of Git and shared folders.
The script
import csv
import os
import sys
from datetime import date, timedelta
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
DateRange,
Dimension,
Metric,
OrderBy,
RunReportRequest,
)
DIMENSIONS = ["date", "sessionDefaultChannelGroup"]
METRICS = ["sessions", "keyEvents"]
PAGE_SIZE = 10000
def fetch(client, property_id, start, end):
rows, problems = [], set()
while True:
response = client.run_report(RunReportRequest(
property=f"properties/{property_id}",
date_ranges=[DateRange(start_date=start, end_date=end)],
dimensions=[Dimension(name=d) for d in DIMENSIONS],
metrics=[Metric(name=m) for m in METRICS],
order_bys=[
OrderBy(dimension=OrderBy.DimensionOrderBy(dimension_name=d))
for d in DIMENSIONS
],
limit=PAGE_SIZE,
offset=len(rows),
return_property_quota=True,
))
rows.extend(response.rows)
meta = response.metadata
if meta.sampling_metadatas:
problems.add("report is sampled")
if meta.data_loss_from_other_row:
problems.add("values were rolled into an (other) row")
if meta.data_truncation_reasons:
problems.add("data is truncated")
if meta.subject_to_thresholding:
print("Note: report is subject to thresholding")
if not response.rows or len(rows) >= response.row_count:
break
if len(rows) != response.row_count:
problems.add(f"got {len(rows)} of {response.row_count} rows")
print("Daily tokens left:", response.property_quota.tokens_per_day.remaining)
return rows, problems
def missing_days(rows, start, end):
found = {row.dimension_values[0].value for row in rows}
first, last = date.fromisoformat(start), date.fromisoformat(end)
days = (first + timedelta(n) for n in range((last - first).days + 1))
return [d.isoformat() for d in days if d.strftime("%Y%m%d") not in found]
def main(start, end, path):
client = BetaAnalyticsDataClient()
rows, problems = fetch(client, os.environ["GA4_PROPERTY_ID"], start, end)
gaps = missing_days(rows, start, end)
if gaps:
problems.add(f"no rows for {', '.join(gaps)}")
if problems:
sys.exit("No file written: " + "; ".join(sorted(problems)))
with open(path, "w", newline="") as file:
writer = csv.writer(file)
writer.writerow(DIMENSIONS + METRICS)
for row in rows:
writer.writerow(
[v.value for v in row.dimension_values]
+ [v.value for v in row.metric_values]
)
print(f"Wrote {len(rows)} rows to {path}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2], sys.argv[3])
Pagination. By default, a report returns 10,000 rows, and one request can return up to 250,000. Google also says the API can return fewer rows than the limit, so the next offset comes from the rows you actually received, not from the page size. In the client library, row_count is the total for the whole query, regardless of limit and offset. That gives you a number to reconcile against. The empty-page condition stops the loop if the two never meet.
Sort order. The script orders every page by both dimensions. Offsets depend on rows staying in one sequence, so an explicit order is a cheap precaution, not a documented requirement.
Quiet changes in meaning. The response metadata reports sampling, an (other) row and truncation. data_loss_from_other_row reflects the underlying data, even if a filter removes that row from your report. This script treats these three as blockers because its contract promises a complete daily table. A different report might accept one of them and label it.
Thresholding. This flag only prints a note. Google says a report subject to thresholding can still be complete, if all of its data clears the threshold. So read the note before you act on small rows, and compare them with the GA4 interface if they matter.
Missing days. The API omits rows where every metric is zero unless you set keep_empty_rows. A day with no rows might be a quiet day or a tracking outage, and only a person can tell which. Stopping forces that question. A missing channel on one day is normal, so the check looks only at dates.
Types. Metric values arrive as text, and the script writes them unchanged. Convert them where you calculate, using the type in the response's metric_headers.
Quotas, failures and scheduling
Each request uses core tokens. A standard property gets 200,000 per day, 40,000 per hour and 14,000 per project per hour. A small report like this should use only a little of that, and the printed balance lets you see it. Exhausting a bucket returns 429 RESOURCE_EXHAUSTED for further requests to that property.
The script has no retry logic of its own, so an error stops it before any file is written. That is often fine for a job you can rerun. If yours cannot wait, Google's quota page recommends retrying 429, 500 and 503 responses with truncated exponential backoff and a maximum number of attempts. Do not retry 403 PERMISSION_DENIED or 400 INVALID_ARGUMENT unchanged. Fix access or the request first.
A script is not always the right tool. Make has a GA4 module for generating reports, and n8n has a Google Analytics node as well as a general HTTP Request node, though check that it supports what your property needs. They suit a simple scheduled export. The checks above still apply, so make sure the workflow can read the metadata and stop on a problem. The same goes for AI-written code: an assistant can draft a report script quickly, but you still have to confirm the field names against the documentation and test the failure paths.
Before you schedule anything, test those paths once. Use a date range that returns many rows, one with a known quiet day, and a dimension with many values. Then compare one small result with the same report in GA4, matching the date range, dimensions and filters. Expect small differences, and explain any large one before the CSV goes to anybody else.