Google Ads Data Integration into a Data Lake looks straightforward when there is only one report.
Build a GAQL query, call the Google Ads API, map the response, and store the data.
The design becomes more complicated when the same integration needs campaign performance, conversions, ad groups, keywords, devices, ads, calls, geographic targeting, and other reporting datasets.
Writing separate API logic for every report would work, but it would also create a lot of repeated code.
Each implementation would need to repeat the same steps:
Authenticate with Google Ads
↓
Create GAQL query
↓
Apply date filter
↓
Execute query for customers
↓
Read streaming response
↓
Normalize fields
↓
Convert field types
↓
Store processed data
Instead, we designed the Google Ads Data Integration around a shared query configuration.
The core execution logic stays the same. What changes from one report to another is mostly configuration: which Google Ads resource to query, which fields to select, which filters to apply, and how the returned fields should be mapped.
This made it possible to support several Google Ads datasets without maintaining a separate implementation for each one. Artificial Intelligence is transforming businesses by improving efficiency, automating processes, and enabling smarter decision-making.
Most Google Ads reports differ in only a few areas.
For example, campaign reporting might query:
SELECT
segments.date,
campaign.id,
campaign.name,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions
FROM campaign
WHERE segments.date BETWEEN ‘2026-08-01’ AND ‘2026-08-01’
AND campaign.status != ‘REMOVED’
A device report still uses the campaign resource, but adds a device dimension:
SELECT
segments.date,
campaign.id,
campaign.name,
segments.device,
metrics.impressions,
metrics.clicks,
metrics.cost_micros
FROM campaign
WHERE segments.date BETWEEN ‘2026-08-01’ AND ‘2026-08-01’
AND campaign.status != ‘REMOVED’
A keyword report changes the resource and fields again:
SELECT
segments.date,
campaign.id,
campaign.name,
ad_group.id,
ad_group.name,
ad_group_criterion.keyword.text,
metrics.impressions,
metrics.clicks,
metrics.cost_micros
FROM keyword_view
WHERE segments.date BETWEEN ‘2026-08-01’ AND ‘2026-08-01’
AND ad_group_criterion.status != ‘REMOVED’
The API workflow around these queries is almost identical.
That was the reason for moving the differences into configuration instead of duplicating the processing code.
Each Google Ads dataset is described using a configuration object.
A simplified version looks like this:
type GoogleAdsQueryConfig = {
sourceId: string;
resource: string;
fields: string[];
where?: string;
columns: {
key: string;
type: string;
googleAdsField?: string;
}[];
fieldMap: Record<string, string>;
};
This configuration contains enough information to build a query and process its response.
For example, campaign reporting can be represented as:
const campaignConfig = {
sourceId: “campaign_performance”,
resource: “campaign”,
fields: [
“segments.date”,
“segments.ad_network_type”,
“campaign.id”,
“campaign.name”,
“metrics.impressions”,
“metrics.clicks”,
“metrics.ctr”,
“metrics.cost_micros”,
“metrics.conversions”,
“metrics.conversions_value”
],
where: “campaign.status != ‘REMOVED'”,
columns: [
{ key: “date”, type: “date” },
{ key: “campaign_id”, type: “id” },
{ key: “campaign_name”, type: “string” },
{ key: “impressions”, type: “integer” },
{ key: “clicks”, type: “integer” },
{ key: “cost”, type: “currency” },
{ key: “conversions”, type: “double” }
]
};
A device report can use the same structure:
const deviceConfig = {
sourceId: “device_performance”,
resource: “campaign”,
fields: [
“segments.date”,
“campaign.id”,
“campaign.name”,
“segments.device”,
“metrics.impressions”,
“metrics.clicks”,
“metrics.cost_micros”
],
where: “campaign.status != ‘REMOVED'”,
columns: [
{ key: “date”, type: “date” },
{ key: “campaign_id”, type: “id” },
{ key: “campaign_name”, type: “string” },
{ key: “device”, type: “enum” },
{ key: “impressions”, type: “integer” },
{ key: “clicks”, type: “integer” },
{ key: “cost”, type: “currency” }
]
};
The important part is that neither configuration contains API execution code.
It only describes what should be requested and how the result should look.
Digital Marketing helps businesses reach the right audience, increase online visibility, generate quality leads, and build stronger customer relationships.
Once query definitions are stored as configuration, the GAQL statement can be generated by one shared function.
A simplified implementation looks like:
function buildGaqlQuery(
config: GoogleAdsQueryConfig,
startDate: string,
endDate: string
) {
const whereParts = [
`segments.date BETWEEN ‘${startDate}’ AND ‘${endDate}’`
];
if (config.where) {
whereParts.push(config.where);
}
return `
SELECT ${config.fields.join(“, “)}
FROM ${config.resource}
WHERE ${whereParts.join(” AND “)}
`;
}
Now the same function works for campaigns, devices, keywords, ad groups, conversions, and other datasets.
For example:
const query = buildGaqlQuery(
campaignConfig,
“2026-08-01”,
“2026-08-01”
);
The query builder does not need to know that it is creating a campaign query.
It simply understands:
Resource
+
Selected Fields
+
Date Condition
+
Additional Filter
=
GAQL Query
This keeps GAQL construction in one place.
If date handling or query formatting needs to change later, the change can be made once instead of across several integrations.
The real benefit appears when several report definitions are registered together.
For example:
const googleAdsConfigs = [
campaignConfig,
conversionConfig,
adGroupConfig,
keywordConfig,
locationConfig,
deviceConfig,
callConfig,
adConfig
];
The common processing layer can accept any one of these configurations:
async function processGoogleAdsQuery(
config: GoogleAdsQueryConfig,
startDate: Date,
endDate: Date
) {
const query = buildGaqlQuery(
config,
formatDate(startDate),
formatDate(endDate)
);
const rows = await fetchGoogleAdsRows(query);
return normalizeRows(rows, config);
}
Nothing inside this function needs a large condition such as:
if (report === “campaign”) {
…
} else if (report === “device”) {
…
} else if (report === “keyword”) {
…
}
The configuration already contains those differences.
The processing flow becomes:
Campaign Config ───────┐
Device Config ─────────┤
Keyword Config ────────┤
Conversion Config ─────┤
Location Config ───────┤
↓
Common Query Processing
↓
Google Ads API
↓
Common Normalization
This is much easier to extend than creating another complete integration every time a new report is required.
One interesting case was geographic reporting.
Not every Google Ads Data Integration can always be combined into one GAQL statement in the way we want to process it.
For geographic data, we may need separate queries for dimensions such as:
Writing five completely separate configurations would create duplication because most of the selected fields are identical.
Instead, the configuration can describe query splits.
For example:
const locationConfig = {
resource: “geographic_view”,
fields: [
“segments.date”,
“campaign.id”,
“campaign.name”,
“geographic_view.country_criterion_id”,
“geographic_view.location_type”,
“segments.geo_target_city”,
“segments.geo_target_region”,
“segments.geo_target_metro”,
“segments.geo_target_postal_code”,
“metrics.impressions”,
“metrics.clicks”,
“metrics.cost_micros”
],
querySplits: [
{ key: “country” },
{
key: “city”,
field: “segments.geo_target_city”
},
{
key: “region”,
field: “segments.geo_target_region”
},
{
key: “metro”,
field: “segments.geo_target_metro”
},
{
key: “postal_code”,
field: “segments.geo_target_postal_code”
}
]
};
The query builder first identifies the fields shared by every query.
It then generates one GAQL statement for each configured split.
A simplified version is:
function buildQueries(config, startDate, endDate) {
if (!config.querySplits?.length) {
return [{
query: buildQuery(
config,
config.fields,
startDate,
endDate
)
}];
}
const splitFields = new Set(
config.querySplits
.map(item => item.field)
.filter(Boolean)
);
const commonFields = config.fields.filter(
field => !splitFields.has(field)
);
return config.querySplits.map(split => ({
key: split.key,
query: buildQuery(
config,
split.field
? […commonFields, split.field]
: commonFields,
startDate,
endDate
)
}));
}
This keeps the geographic definition in one configuration while still allowing several API queries to be generated.
The result is conceptually:
Geographic Configuration
↓
Shared Fields
↓
┌───────────────┼───────────────┐
↓ ↓ ↓
City Query Region Query Postal Query
↓ ↓ ↓
└───────────────┼───────────────┘
↓
Common Processing
Each response can also carry its query key:
{
row: apiRow,
queryKey: “city”
}
That makes it possible to identify which geographic level produced the row without creating separate processing logic for every query.
Integration on AWS connects applications, data, and services for seamless and efficient cloud operations.
All generated queries eventually use the same Google Ads API function.
The implementation uses the Google Ads searchStream endpoint.
At a simplified level:
async function fetchGoogleAdsRows(
customerId: string,
query: string
) {
const response = await googleAdsRequest(
`customers/${customerId}/googleAds:searchStream`,
{ query }
);
const rows = [];
for (const part of response) {
if (part.results) {
rows.push(…part.results);
}
}
return rows;
}
Whether the query is for campaigns, keywords, devices, or geographic data does not change this API layer.
For a configuration containing several generated queries, the same method is simply called more than once:
const queries = buildQueries(
config,
startDate,
endDate
);
const results = [];
for (const query of queries) {
const rows = await fetchGoogleAdsRows(
customerId,
query.query
);
results.push(
…rows.map(row => ({
row,
queryKey: query.key
}))
);
}
This separation keeps API communication independent from report definition.
Google Ads Data Integration responses also need normalization before being used by the rest of the reporting system.
For example:
Google Ads field Internal field
—————- ————–
segments.date date
campaign.id campaign_id
campaign.name campaign_name
metrics.impressions impressions
metrics.cost_micros cost
That mapping is also stored in configuration.
const fieldMap = {
date: “date”,
campaign_id: “campaign_id”,
campaign_name: “campaign_name”,
impressions: “impressions”,
cost: “cost”
};
A shared normalization function can then process any configured dataset:
function normalizeRow(flatRow, config) {
const result = {};
for (const [sourceKey, destinationKey]
of Object.entries(config.fieldMap)) {
const column = config.columns.find(
item => item.key === destinationKey
);
result[destinationKey] = convertValue(
flatRow[sourceKey],
column?.type
);
}
return result;
}
This gives one place to handle common conversions for:
For example, Google Ads cost is returned using cost_micros, so currency normalization can also remain inside the shared processing layer instead of being repeated by every query definition.
With this design, adding another dataset usually does not require another complete API implementation.
The main work is defining:
For example:
const newReportConfig = {
sourceId: “new_google_ads_report”,
resource: “some_google_ads_resource”,
fields: [
“segments.date”,
“campaign.id”,
“campaign.name”,
“metrics.impressions”
],
where: “some.status != ‘REMOVED'”,
columns: [
{ key: “date”, type: “date” },
{ key: “campaign_id”, type: “id” },
{ key: “campaign_name”, type: “string” },
{ key: “impressions”, type: “integer” }
],
fieldMap: {
date: “date”,
campaign_id: “campaign_id”,
campaign_name: “campaign_name”,
impressions: “impressions”
}
};
After registering the configuration, the existing query construction, API execution, normalization, and common processing can be reused.
That is much smaller in scope than copying an existing implementation and modifying it for another report.
The biggest advantage was not reducing the number of files. It was reducing the number of places where the same behavior had to be maintained.
The Google Ads Api Integration now has a clear separation:
Configuration
“What data do we need?”
↓
Query Builder
“How should the GAQL query be created?”
↓
API Layer
“How do we retrieve Google Ads data?”
↓
Normalizer
“How should Google fields become internal fields?”
↓
Common Processing
“What do we do with the normalized records?”
Each part has one responsibility.
When a new Google Ads query is required, most changes remain in configuration.
When API authentication changes, report configurations do not need to change.
When date-query construction changes, it can be updated centrally.
When a new field type needs normalization, one shared conversion layer can handle it.
And when a report requires several related GAQL queries, query splitting allows those queries to remain part of one logical configuration instead of becoming several independent implementations.
Google Ads contains many resources and reporting dimensions, so it is easy for an integration to grow into a collection of similar query scripts.
The approach that worked better for us was to treat GAQL reports as data definitions rather than separate implementations.
A configuration describes the resource, selected fields, filters, expected output, and any required query splits. The shared processing layer handles the rest.
The final pattern is simple:
Multiple Query Configurations
↓
Common GAQL Builder
↓
Google Ads Search Stream
↓
Common Field Normalization
↓
Standardized Data
This keeps the integration easier to extend while still allowing individual Google Ads reports to have their own fields, filters, resources, and special query requirements.
For integrations that need to support many related API queries, separating what to query from how queries are executed can remove a large amount of duplicated code without making the implementation overly abstract.