Every month, I used to repeat the same reporting routine:
- open Google Analytics 4
- open Google Search Console
- change the date range
- export the data
- compare it with the previous month
- build charts
- write observations
- repeat everything for another website
At some point, I realized that most of this process was not analysis.
It was data collection and formatting.
So I decided to automate it with Python.
The goal was simple:
Run one command and generate the same monthly website report I would normally build manually.
This article explains the structure I used, what data I collect, and a few lessons I learned while building it.
I manage several websites, so I wanted the script to answer a few basic questions:
- How many users visited the website?
- How many sessions were recorded?
- How many organic clicks came from Google Search?
- How many impressions did the website receive?
- What was the average CTR?
- What was the average position?
- Which websites improved or declined?
I deliberately avoided building a complicated dashboard.
The first version only needed to produce a consistent monthly report.
The workflow looked like this:
Google Analytics 4
↓
Google Search Console
↓
Python
↓
Normalize the data
↓
Calculate metrics
↓
Generate charts
↓
Create PDF report
The Python Packages
My basic stack uses:
google-api-python-client
google-auth
google-analytics-data
reportlab
matplotlib
Install them with:
pip install google-api-python-client google-auth google-analytics-data reportlab matplotlib
The Google libraries handle the API requests.
matplotlib handles simple charts.
ReportLab generates the final PDF.
Setting Up Authentication
The first part that took some time was Google authentication.
I use a Google Cloud Service Account.
The general process is:
- Create a Google Cloud project.
- Enable the Google Analytics Data API.
- Enable the Search Console API.
- Create a Service Account.
- Download the JSON credentials file.
- Give the Service Account access to GA4.
- Give the same Service Account access to Search Console.
Once that is done, the Python script can authenticate without requiring a browser login every time.
A basic authentication setup might look like:
from google.oauth2 import service_accountcredentials = service_account.Credentials.from_service_account_file("service-account.json")
One lesson I learned here:
Keep the credentials file outside your public repository.
Add it to .gitignore:
service-account.json
Never commit API credentials to GitHub.
Pulling Data From GA4
For Google Analytics 4, I use the Analytics Data API.
Here is a simplified example:
from google.analytics.data_v1beta import BetaAnalyticsDataClientfrom google.analytics.data_v1beta.types import (DateRange,Metric, RunReportRequest,)client = BetaAnalyticsDataClient(credentials=credentials)request = RunReportRequest(property="properties/123456789",metrics=[ Metric(name="activeUsers"),Metric(name="sessions"),],date_ranges=[ DateRange(start_date="2026-09-01",end_date="2026-09-30")])response = client.run_report(request)
Then I extract the returned values:
row = response.rows[0]active_users = int(row.metric_values[0].value)sessions = int(row.metric_values[1].value)
This gives me two of the main metrics I need.
Why I Keep GA4 Metrics Simple
GA4 exposes many metrics.
It is tempting to pull everything.
I tried that approach at first.
It made the report worse.
More data created more noise.
For my monthly report, I prefer to start with:
Active Users
Sessions
Then add more only when there is a clear reason.
For example:
Engaged Sessions
Conversions
Event Count
Views
The rule I use now is:
If a metric does not change a decision, it probably does not need to be in the main report.
Pulling Search Console Data
Search Console gives me the organic search side.
A basic request looks like this:
from googleapiclient.discovery import buildservice = build( "searchconsole","v1",credentials=credentials)request = {"startDate": "2026-09-01","endDate":"2026-09-30","searchType": "web"}response=( service.searchanalytics().query(siteUrl="https: //example.com/", body=request).execute())
From Search Console, I collect:
Clicks
Impressions
CTR
Average Position
If I want page-level data, I add dimensions:
request = {"startDate": "2026-09-01","endDate": "2026-09-30", "dimensions": ["page"],"rowLimit": 25000}
For keyword-level data:
"dimensions": ["query"]
This makes the same API useful for both executive reporting and deeper SEO analysis.
Normalizing the Data
GA4 and Search Console return different response structures.
Before generating a report, I convert everything into a common Python dictionary.
For example:
website_data = {"website": "example.com","users": 24500,"sessions": 31800, "clicks": 9200,"impressions": 210000,"ctr": 4.38,"position": 9.2}
Then multiple websites become:
websites = [website_1,website_2,website_3]
This step is important.
Once the data has a consistent structure, the rest of the program becomes much easier.
Charts do not care whether the original metric came from GA4 or Search Console.
The PDF generator does not need to know either.
It only reads the normalized data.
Calculating Changes
Raw numbers are useful, but changes are usually more interesting.
A simple percentage change function:
def percentage_change(current, previous): if previous == 0: return None return ((current - previous) / previous) * 100
Example:
previous_clicks = 10000current_clicks = 8500change = percentage_change( current_clicks,previous_clicks)print(change)
Output:
-15.0
Now the report can say:
Organic clicks decreased by 15%.
That is more useful than simply showing:
Clicks: 8,500
Adding Simple Rules
I also wanted the report to highlight things worth investigating.
I started with very simple rules.
Example:
def analyze_seo(impressions, ctr, position):notes = []if impressions > 1000 and ctr < 2:notes.append("High visibility but low CTR." ) if 4 <= position <= 10:notes.append("Page-one ranking opportunity." )return notes
This does not replace SEO analysis.
It simply points me toward possible opportunities.
The distinction matters.
I do not want the script to say:
Traffic dropped because of a Google algorithm update.
The available data cannot prove that.
It can say:
Clicks declined by 30% while impressions stayed stable.
That is a factual signal.
A person can investigate the reason.
Generating Charts
For simple report charts, Matplotlib is enough.
Example:
import matplotlib.pyplot as pltsites = ["Site A","Site B","Site C"]clicks = [8200,5100,3400]plt.bar(sites, clicks)plt.title("Organic Clicks")plt.ylabel("Clicks")plt.savefig("organic-clicks.png", bbox_inches="tight")plt.close()
I intentionally keep the visualizations simple.
The report is meant to answer questions, not demonstrate how many chart types Matplotlib supports.
Generating the PDF
For the PDF report, I use ReportLab.
A very basic example:
from reportlab.lib.pagesizes import A4from reportlab.platypus import ( SimpleDocTemplate,Paragraph,Spacer)from reportlab.lib.styles import getSampleStyleSheetstyles = getSampleStyleSheet()document = SimpleDocTemplate("monthly-report.pdf",pagesize=A4)content = []content.append(Paragraph("Monthly Website Performance Report", styles["Title"]))content.append(Spacer(1, 20))content.append( Paragraph("This report contains GA4 and Google Search Console data.", styles["BodyText"]))document.build(content)
From there I can add:
- tables
- charts
- page breaks
- recommendations
- website summaries The final PDF is generated automatically.
Supporting Multiple Websites
The next step was making the script work across multiple websites.
I created a simple configuration structure:
sites = [{"name": "Site A","ga4_property": "123456789","gsc_url":"https ://site-a.com/"},{"name": "Site B","ga4_property": "987654321", "gsc_url": "https ://site-b.com/"}]
Then the script loops through them for site in sites:
ga4_data = get_ga4_data(site["ga4_property"])gsc_data = get_gsc_data(site["gsc_url"])generate_site_report(site, ga4_data,gsc_data)
This was the point where the project became genuinely useful for me.
Instead of running five different reporting workflows, I had one.
Error Handling Matters More Than I Expected
APIs fail.
Credentials expire.
Properties are misconfigured.
Sometimes a website has no data for a period.
My first version assumed everything would work.
That was a mistake.
Now I prefer explicit error handling:
try: ga4_data = get_ga4_data(property_id)except Exception as error: print(f"GA4 error: {error}")ga4_data = None
The report should still be generated even if one data source fails.
Instead of crashing, it can display:
GA4 data unavailable.
Check API access or property configuration.
This makes the automation much more usable in real work.
One Command Instead of a Monthly Routine
The old workflow looked like this:
Open GA4
↓
Export report
↓
Open Search Console
↓
Export report
↓
Clean CSV
↓
Create charts
↓
Calculate metrics
↓
Build PDF
↓
Repeat
Now it looks more like:
python generate_report.py
The script:
- authenticates with Google;
- retrieves GA4 data;
- retrieves Search Console data;
- calculates metrics;
- generates charts;
- creates the PDF.
The part I still do manually is the most important part:
deciding what the numbers mean.
What I Learned
A few lessons became clear while building this.
Automate repetition, not judgment
APIs are good at collecting data.
Python is good at transforming it.
Neither automatically knows why traffic changed.Start with fewer metrics
My first instinct was to collect everything.
A smaller report turned out to be more useful.Normalize early
Converting API responses into a consistent data structure makes everything after that easier.Make failures visible
Do not silently replace missing API data with zero.
A zero can look like a real metric.
Use explicit statuses such as:
NO DATA
API ERROR
NOT CONFIGUREDBuild the smallest useful version first
The project did not need:authentication UI
a web dashboard
user accounts
a database
AI-generated insights
The first useful version was simply:
API → Python → PDF
That was enough.
Where I Want to Take It Next
There are a few improvements I would like to build.
Multi-month trend detection
Instead of comparing only two periods, the script could identify longer-term traffic decay.
Query clustering
Search Console queries could be grouped by topic or intent.
Automated anomaly detection
Instead of fixed thresholds, the script could learn what normal traffic looks like for each site.
Better AI referral tracking
AI assistants are becoming another source of website discovery, but analytics attribution can still be messy.
Web interface
Eventually, the Python reporting workflow could become a small web application.
But I would still keep the same basic architecture:
Data sources
↓
Collection
↓
Normalization
↓
Analysis
↓
Presentation
Source Code vs. The Idea
I wrote a more detailed guide about my actual setup, including Google Service Account configuration and the reporting workflow, here:
How to Automate GA4 + Google Search Console Reports
The interesting part for me, however, was not the exact Python code.
It was realizing how much time I was spending on a process that followed exactly the same steps every month.
Once a workflow becomes predictable enough to write as:
Step A
Step B
Step C
Step D
there is a good chance some of it can be automated.
Final Thoughts
This project started because I did not want to build five website reports manually every month.
It ended up changing how I think about internal tools.
You do not always need to build a SaaS product.
You do not always need a complex dashboard.
Sometimes a small Python script that solves one repetitive problem is enough.
The current workflow is not meant to replace Google Analytics or Search Console.
It simply removes the repetitive steps between data exists and:
I can actually analyze it
And for me, that was worth automating.
Top comments (0)