Analytics

Calling the GA4 Data API and Search Console API in Python

A practical guide to pulling live data directly from Google Analytics 4 and Search Console with Python, covering authentication, report requests, pagination, joining the two datasets and building a reusable extraction layer.

Ahmed Khalil Ben Smida7 min read

The GA4 and Search Console dashboards are useful for a glance, but they are the wrong tool the moment you need to join analytics data with anything else: a CRM pipeline, a spreadsheet a client actually reads, or a model that needs raw numbers rather than a pre-aggregated chart. Both platforms expose a proper API for exactly this, and pulling live data programmatically is what turns reporting from a monthly export ritual into a system that updates itself.

This guide walks through authenticating to both APIs from Python, requesting the reports you actually need, handling pagination correctly, and joining traffic data to search performance so you can answer questions neither tool answers alone, such as which queries are driving traffic that converts.

Authenticate once with a service account

Both APIs accept a Google Cloud service account, which is the right choice for anything unattended, since it needs no interactive login and can be scoped precisely. Create the service account in Google Cloud Console, grant it viewer access on the GA4 property and add it as a user in Search Console, then load its key file once and build both clients from the same credentials object.

from google.oauth2 import service_account
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from googleapiclient.discovery import build

SCOPES = [
    "https://www.googleapis.com/auth/analytics.readonly",
    "https://www.googleapis.com/auth/webmasters.readonly",
]

def build_clients(key_file):
    creds = service_account.Credentials.from_service_account_file(
        key_file, scopes=SCOPES)
    ga = BetaAnalyticsDataClient(credentials=creds)
    gsc = build("searchconsole", "v1", credentials=creds)
    return ga, gsc

One credentials file, two clients, no browser flow. This is what makes the extraction safe to run on a schedule rather than by hand.

Request a GA4 report with the right shape

The GA4 Data API separates dimensions, the things you group by, from metrics, the numbers you sum. Getting the shape right up front avoids a second call: ask for date, channel grouping and landing page as dimensions, and sessions, engaged sessions and conversions as metrics, over a rolling window expressed relative to today rather than a hard-coded date.

from google.analytics.data_v1beta.types import (
    RunReportRequest, DateRange, Dimension, Metric)

def ga4_report(client, property_id, days=28):
    request = RunReportRequest(
        property=f"properties/{property_id}",
        date_ranges=[DateRange(start_date=f"{days}daysAgo", end_date="today")],
        dimensions=[Dimension(name="date"),
                    Dimension(name="sessionDefaultChannelGroup"),
                    Dimension(name="landingPagePlusQueryString")],
        metrics=[Metric(name="sessions"),
                 Metric(name="engagedSessions"),
                 Metric(name="conversions")],
    )
    resp = client.run_report(request)
    rows = []
    for r in resp.rows:
        rows.append({
            "date": r.dimension_values[0].value,
            "channel": r.dimension_values[1].value,
            "page": r.dimension_values[2].value,
            "sessions": int(r.metric_values[0].value),
            "engaged": int(r.metric_values[1].value),
            "conversions": int(r.metric_values[2].value),
        })
    return rows

Keep the dimension list short. Every additional dimension increases cardinality and pushes you toward the API’s row limits, which is exactly the pagination problem the next section solves.

Paginate properly, do not assume one page is everything

Any report over a meaningful date range or with high-cardinality dimensions like landing page will exceed a single response page. The API returns a row count and accepts an offset, so the correct pattern loops until the returned rows fall short of the requested limit.

def ga4_report_all(client, property_id, days=28, page_size=100000):
    offset, all_rows = 0, []
    while True:
        request = RunReportRequest(
            property=f"properties/{property_id}",
            date_ranges=[DateRange(start_date=f"{days}daysAgo", end_date="today")],
            dimensions=[Dimension(name="date"), Dimension(name="landingPagePlusQueryString")],
            metrics=[Metric(name="sessions")],
            limit=page_size, offset=offset,
        )
        resp = client.run_report(request)
        all_rows.extend(resp.rows)
        if len(resp.rows) < page_size:
            return all_rows
        offset += page_size

Silently trusting the first page is one of the most common mistakes in GA4 automation. It produces a report that looks complete, passes a casual glance, and quietly under-reports every metric on any property with meaningful traffic, and the discrepancy tends to grow worse over time as a site’s traffic and page count increase, which means a report that looked accurate when it was first built can drift silently wrong months later without any code having changed at all.

Pull Search Console the same disciplined way

Search Console’s query method takes a similar shape: dimensions, a date range, and row limits that also need explicit pagination through startRow. The dimension you almost always want alongside query and page is date, so you can later align it to the same daily grain as your GA4 pull.

def gsc_query(gsc, site_url, days=28, row_limit=25000):
    from datetime import date, timedelta
    start = (date.today() - timedelta(days=days)).isoformat()
    end = date.today().isoformat()
    start_row, all_rows = 0, []
    while True:
        body = {
            "startDate": start, "endDate": end,
            "dimensions": ["date", "query", "page"],
            "rowLimit": row_limit, "startRow": start_row,
        }
        resp = gsc.searchanalytics().query(siteUrl=site_url, body=body).execute()
        rows = resp.get("rows", [])
        all_rows.extend(rows)
        if len(rows) < row_limit:
            return all_rows
        start_row += row_limit

Search Console’s numbers are sampled and revised for a few days after they occur, so a daily pull should always re-fetch the last three to five days rather than only the newest day, or your historical figures will quietly drift below the values Google eventually settles on.

Join the two datasets on page and date

Neither API alone answers the question that actually matters to a business: which search queries bring traffic that converts. Joining GA4 sessions to Search Console clicks on the shared page and date dimension gets you there, provided the URLs are normalised the same way on both sides first.

from urllib.parse import urlparse

def norm_path(url_or_page):
    return urlparse(url_or_page).path.rstrip("/") or "/"

def join_traffic_and_search(ga4_rows, gsc_rows):
    ga_by_key = {}
    for r in ga4_rows:
        key = (r["date"], norm_path(r["page"]))
        ga_by_key.setdefault(key, {"sessions": 0, "conversions": 0})
        ga_by_key[key]["sessions"] += r["sessions"]
        ga_by_key[key]["conversions"] += r.get("conversions", 0)

    joined = []
    for r in gsc_rows:
        key = (r["keys"][0], norm_path(r["keys"][2]))
        ga = ga_by_key.get(key, {"sessions": 0, "conversions": 0})
        joined.append({
            "date": key[0], "page": key[1], "query": r["keys"][1],
            "clicks": r["clicks"], "impressions": r["impressions"],
            "sessions": ga["sessions"], "conversions": ga["conversions"],
        })
    return joined

The joined table is where the interesting analysis starts: queries with high impressions but low click-through are a title and meta-description opportunity, and pages with strong clicks but weak conversions are a landing-page problem rather than a visibility problem. Neither insight is visible from either tool alone.

Build it as a reusable extraction layer

The value of this work compounds once it is wrapped as a small, reusable module rather than a one-off script. A single function that returns a clean, joined table can feed a scheduled export to a warehouse, a dashboard, or a weekly summary, and the same authentication and pagination logic serves every property you manage without being rewritten. Store the raw pulls too, not just the joined view, because raw data lets you re-derive any future analysis without re-calling the API and burning quota on a question you already answered.

def extract(property_id, site_url, key_file, days=28):
    ga, gsc = build_clients(key_file)
    ga_rows = ga4_report_all(ga, property_id, days)
    gsc_rows = gsc_query(gsc, site_url, days)
    return join_traffic_and_search(ga_rows, gsc_rows)

Handle quota and errors like a citizen, not a guest

Both APIs enforce daily and per-minute quotas, and a script that ignores them will eventually fail at the worst possible time, typically during a scheduled run nobody is watching. Wrap every call in a retry with exponential backoff for rate-limit and transient server errors, and fail loudly rather than silently on anything else, so a genuine authentication or permissions problem surfaces immediately instead of being swallowed by an overly broad exception handler.

import time
from googleapiclient.errors import HttpError

def call_with_retry(fn, *args, max_retries=5, **kwargs):
    for attempt in range(max_retries):
        try:
            return fn(*args, **kwargs)
        except HttpError as e:
            if e.resp.status in (429, 500, 503) and attempt < max_retries - 1:
                time.sleep(2 ** attempt)
                continue
            raise

Respecting quota is not just good manners toward Google’s infrastructure; it is what keeps a scheduled extraction reliable enough that nobody needs to babysit it. A pipeline that occasionally fails loudly and recovers on its own retry is infrastructure. One that fails silently and produces a quietly incomplete report is a liability that erodes trust in every number it produces.

Normalise dimension values before anything downstream touches them

Both APIs are precise about what they return, and that precision does not always match what a human expects. GA4’s default channel grouping strings, Search Console’s page URLs with or without trailing slashes, and date formats that differ subtly between the two APIs all need to be normalised to one convention before joining or aggregating, or subtle bugs creep in that are hard to spot because the numbers still look plausible, just quietly wrong.

def normalise_row(row, source):
    row = dict(row)
    if source == "gsc" and "page" in row:
        row["page"] = row["page"].rstrip("/") or "/"
    if "date" in row:
        row["date"] = row["date"].replace("/", "-")
    return row

This kind of normalisation feels like unnecessary ceremony the first time you write it, and becomes obviously essential the first time a join silently drops half its rows because one side used a trailing slash and the other did not.

Why this replaces the export habit, not just the dashboard

Teams that adopt an API-driven extraction often expect the payoff to be a nicer dashboard, and that is real but secondary. The larger shift is behavioural: once the data lives somewhere queryable and refreshes on its own schedule, the monthly ritual of logging into two separate consoles, exporting two spreadsheets and manually reconciling them by hand simply stops happening, because it is no longer the fastest way to get an answer. That change matters more than it sounds, because the manual export habit is exactly what causes most organisations to only look at their search and traffic data once a month, at reporting time, rather than continuously. A live extraction removes the friction that made infrequent checking rational, and teams that have it tend to notice problems, whether a ranking drop, a tracking break, or a sudden traffic shift, within days rather than discovering them a month later buried in a routine report nobody read closely. The API work described here is not really about the API; it is about collapsing the distance between a question occurring to someone and that question having an answer, from weeks down to the time it takes to run a query.

A note on property access and permissions

Getting the permissions right the first time saves a frustrating debugging session later, because both APIs fail with errors that do not always point clearly at the actual cause. The service account needs to be added as a viewer, or a stronger role, directly inside the GA4 property’s admin settings, not just granted access at the Google Cloud project level, since the two permission systems are separate and a service account can have full cloud access while still being invisible to the analytics property itself. Search Console works similarly: the service account’s email address must be added as a user under the property’s settings before any query will succeed, and a request against a property it has not been added to fails with a permissions error that looks identical to a malformed request, which is the detail that costs most people their first hour debugging.

Key takeaways

  1. Authenticate once with a service account scoped to both APIs; it needs no interactive login.
  2. Request only the dimensions you need; every extra one multiplies row count and pagination cost.
  3. Always paginate GA4 and Search Console reports; trusting the first page silently under-reports.
  4. Re-fetch the last few days of Search Console data on every run, since it is sampled and revised.
  5. Normalise URLs consistently before joining the two datasets on page and date.
  6. Wrap the whole extraction as a reusable function that returns a clean table, and persist the raw pulls.

Once traffic and search performance live in one table you control, reporting stops being a monthly export and becomes a live, queryable asset. Everything downstream, from a dashboard to an attribution model, gets simpler once this layer exists.

References

Apply this to your business

04Measurement and optimisation

Tracking and analytics

Trustworthy measurement from first click to revenue, visible in dashboards the team actually uses.

Conversion tracking, GA4, Tag Manager and dashboards implemented properly, so every marketing decision is made on data you can trust.

  • Google Tag Manager
  • GA4
  • Looker Studio
  • Microsoft Clarity
View capability
07Strategy and growth

SEO and content

A content architecture and editorial workflow that grows qualified organic traffic quarter after quarter.

Technical SEO, topic architecture and editorial systems that compound. Programmatic and AI-assisted approaches only where they genuinely fit.

  • Semrush
  • Google Search Console
  • Screaming Frog
  • n8n
View capability
01Strategy and growth

Marketing strategy

A clear positioning, channel plan and KPI framework that the whole organisation can execute against.

Market analysis, positioning and a growth plan your team can actually execute, built by someone who also implements the systems behind it.

  • GA4
  • Semrush
  • HubSpot
  • Looker Studio
View capability