<!-- Cogny documentation. Canonical page: https://cogny.com/docs/google-ads-scripts -->

# Google Ads Scripts Reference

Complete reference guide to Google Ads Scripts: JavaScript automation within Google Ads, AdsApp object model, selectors and iterators, GAQL queries, common script patterns with working code, Google Sheets integration, MCC scripts, and best practices.

**Author:** Cogny Team  
**Published:** 2025-02-11  
**Updated:** 2026-10-09  
**Canonical:** https://cogny.com/docs/google-ads-scripts

## Overview

Google Ads Scripts let you programmatically control your Google Ads account using JavaScript. Scripts run directly inside the Google Ads web interface — no external server, no API keys, no OAuth setup. You write JavaScript, Google executes it on a schedule, and your account changes happen automatically.

**Key capabilities:**

- Read and modify campaigns, ad groups, ads, keywords, and extensions
- Query performance data using GAQL (Google Ads Query Language)
- Integrate with Google Sheets for dashboards and logging
- Send email alerts via MailApp
- Make HTTP requests to external APIs via UrlFetchApp
- Run on a schedule (hourly, daily, weekly, monthly)

**Single-account vs MCC scripts:**

| Feature | Single-Account Script | MCC Script |
|---------|----------------------|------------|
| Scope | One Google Ads account | All accounts under an MCC |
| Entry point | `main()` | `main()` with `AdsManagerApp` |
| Execution limit | 30 minutes | 60 minutes |
| Parallel execution | No | Yes, via `executeInParallel()` |
| Access object | `AdsApp` | `AdsManagerApp` + `AdsApp` per account |

**Language support:** Google Ads Scripts use a subset of JavaScript (ES5 with some ES6 features). `let`, `const`, arrow functions, and template literals work. `async`/`await`, `import`/`export`, and most ES6+ features do not.

## AdsApp Object Model

The `AdsApp` object is the root of all single-account operations. It provides access to every entity type in the account.

### Entity Hierarchy

```
AdsApp
  +-- campaigns()
  |     +-- adGroups()
  |     |     +-- ads()
  |     |     +-- keywords()
  |     |     +-- audiences()
  |     +-- extensions()
  +-- adGroups()        (account-level shortcut)
  +-- ads()             (account-level shortcut)
  +-- keywords()        (account-level shortcut)
  +-- negativeKeywords()
  +-- shoppingCampaigns()
  +-- videoCampaigns()
  +-- labels()
  +-- budgets()
  +-- biddingStrategies()
```

### Common Entity Methods

Every entity supports a core set of methods:

```javascript
// Status
entity.isEnabled()
entity.isPaused()
entity.isRemoved()
entity.enable()
entity.pause()
entity.remove()

// Naming
entity.getName()
entity.setName('New Name')

// Stats (requires date range)
entity.getStatsFor('LAST_30_DAYS')
entity.getStatsFor('20250101', '20250131')

// Stats object methods
var stats = entity.getStatsFor('LAST_30_DAYS');
stats.getImpressions()
stats.getClicks()
stats.getCtr()
stats.getAverageCpc()
stats.getCost()
stats.getConversions()
stats.getConversionRate()
stats.getAveragePosition()  // deprecated
```

## Selectors and Iterators

Every entity collection in Google Ads Scripts uses the selector-iterator pattern. This is the fundamental data access pattern you will use in every script.

### Selector Methods

```javascript
// Basic selector chain
var keywords = AdsApp.keywords()
  .withCondition('Status = ENABLED')
  .withCondition('CampaignStatus = ENABLED')
  .withCondition('AdGroupStatus = ENABLED')
  .withCondition('Ctr < 0.01')
  .forDateRange('LAST_30_DAYS')
  .orderBy('Impressions DESC')
  .withLimit(100)
  .get();
```

**`.withCondition(condition)`** — Filter entities. Supports operators: `=`, `!=`, `>`, `<`, `>=`, `<=`, `CONTAINS`, `DOES_NOT_CONTAIN`, `STARTS_WITH`, `CONTAINS_IGNORE_CASE`, `DOES_NOT_CONTAIN_IGNORE_CASE`, `REGEXP_MATCH`, `IN []`.

```javascript
// String matching
.withCondition('Name CONTAINS "brand"')
.withCondition('Name REGEXP_MATCH "^(buy|shop|order).*"')

// Numeric conditions
.withCondition('QualityScore > 5')
.withCondition('MaxCpc > 0.50')

// Status conditions
.withCondition('Status = ENABLED')
.withCondition('CampaignStatus != REMOVED')

// Label conditions
.withCondition('LabelNames CONTAINS_ANY ["Priority", "Monitor"]')

// IN operator
.withCondition('CampaignName IN ["Search - Brand", "Search - Generic"]')
```

**`.forDateRange(dateRange)`** — Required for metrics-based conditions. Predefined ranges: `TODAY`, `YESTERDAY`, `LAST_7_DAYS`, `THIS_WEEK_SUN_TODAY`, `THIS_WEEK_MON_TODAY`, `LAST_WEEK`, `LAST_14_DAYS`, `LAST_30_DAYS`, `LAST_BUSINESS_WEEK`, `LAST_WEEK_SUN_SAT`, `THIS_MONTH`, `LAST_MONTH`, `ALL_TIME`.

```javascript
// Custom date range
.forDateRange('20250101', '20250131')
```

**`.orderBy(orderSpec)`** — Sort results. Supports `ASC` and `DESC`.

```javascript
.orderBy('Impressions DESC')
.orderBy('Ctr ASC')
```

**`.withLimit(limit)`** — Cap the number of results.

### Iterator Pattern

```javascript
var iterator = AdsApp.keywords()
  .withCondition('Status = ENABLED')
  .forDateRange('LAST_7_DAYS')
  .withCondition('Impressions > 100')
  .get();

while (iterator.hasNext()) {
  var keyword = iterator.next();
  var stats = keyword.getStatsFor('LAST_7_DAYS');
  Logger.log(keyword.getText() + ': ' + stats.getClicks() + ' clicks');
}
```

## GAQL in Scripts

Google Ads Query Language (GAQL) provides SQL-like access to the full Google Ads API from within scripts. Use it when you need fields not available through the standard selector interface.

### AdsApp.search(query)

Returns an iterator of result rows. Each row is a JavaScript object mirroring the GAQL resource structure.

```javascript
function main() {
  var query = "SELECT " +
    "campaign.name, " +
    "campaign.status, " +
    "metrics.impressions, " +
    "metrics.clicks, " +
    "metrics.cost_micros, " +
    "metrics.conversions " +
    "FROM campaign " +
    "WHERE campaign.status = 'ENABLED' " +
    "AND segments.date DURING LAST_30_DAYS " +
    "ORDER BY metrics.cost_micros DESC";

  var results = AdsApp.search(query);

  while (results.hasNext()) {
    var row = results.next();
    Logger.log(row.campaign.name +
      ' | Cost: $' + (row.metrics.costMicros / 1000000).toFixed(2) +
      ' | Conversions: ' + row.metrics.conversions);
  }
}
```

### AdsApp.report(query)

Returns a report object with export capabilities. Useful for bulk data extraction and Google Sheets integration.

```javascript
function main() {
  var report = AdsApp.report(
    "SELECT " +
    "ad_group_criterion.keyword.text, " +
    "ad_group_criterion.quality_info.quality_score, " +
    "metrics.impressions, " +
    "metrics.clicks, " +
    "metrics.conversions " +
    "FROM keyword_view " +
    "WHERE campaign.status = 'ENABLED' " +
    "AND ad_group.status = 'ENABLED' " +
    "AND ad_group_criterion.status = 'ENABLED' " +
    "AND segments.date DURING LAST_30_DAYS"
  );

  // Export to Google Sheets
  var sheet = SpreadsheetApp.openByUrl('YOUR_SHEET_URL').getActiveSheet();
  report.exportToSheet(sheet);
}
```

### Search vs Report

| Feature | `AdsApp.search()` | `AdsApp.report()` |
|---------|-------------------|-------------------|
| Return type | Row iterator | Report object |
| Field access | `row.campaign.name` | Row iterator or `exportToSheet()` |
| Sheet export | Manual | Built-in `exportToSheet()` |
| Best for | Processing rows individually | Bulk export, dashboards |

## Common Script Patterns

### 1. Pause Low-Performing Keywords

Find keywords below a quality score or CTR threshold and pause them.

```javascript
function main() {
  var CONFIG = {
    MIN_QUALITY_SCORE: 4,
    MIN_CTR: 0.005,         // 0.5%
    MIN_IMPRESSIONS: 100,   // Minimum data threshold
    DATE_RANGE: 'LAST_30_DAYS',
    DRY_RUN: true           // Set to false to actually pause
  };

  var query = "SELECT " +
    "ad_group_criterion.keyword.text, " +
    "ad_group_criterion.keyword.match_type, " +
    "ad_group_criterion.quality_info.quality_score, " +
    "ad_group.name, " +
    "campaign.name, " +
    "metrics.impressions, " +
    "metrics.clicks, " +
    "metrics.ctr, " +
    "metrics.cost_micros, " +
    "metrics.conversions " +
    "FROM keyword_view " +
    "WHERE campaign.status = 'ENABLED' " +
    "AND ad_group.status = 'ENABLED' " +
    "AND ad_group_criterion.status = 'ENABLED' " +
    "AND metrics.impressions > " + CONFIG.MIN_IMPRESSIONS + " " +
    "AND segments.date DURING " + CONFIG.DATE_RANGE;

  var results = AdsApp.search(query);
  var pauseCount = 0;

  while (results.hasNext()) {
    var row = results.next();
    var qs = row.adGroupCriterion.qualityInfo.qualityScore;
    var ctr = row.metrics.ctr;

    if (qs < CONFIG.MIN_QUALITY_SCORE || ctr < CONFIG.MIN_CTR) {
      Logger.log('PAUSE: "' + row.adGroupCriterion.keyword.text + '"' +
        ' | QS: ' + qs +
        ' | CTR: ' + (ctr * 100).toFixed(2) + '%' +
        ' | Campaign: ' + row.campaign.name +
        ' | AdGroup: ' + row.adGroup.name);

      if (!CONFIG.DRY_RUN) {
        var keywords = AdsApp.keywords()
          .withCondition('Text = "' + row.adGroupCriterion.keyword.text + '"')
          .withCondition('AdGroupName = "' + row.adGroup.name + '"')
          .withCondition('CampaignName = "' + row.campaign.name + '"')
          .get();

        if (keywords.hasNext()) {
          keywords.next().pause();
        }
      }
      pauseCount++;
    }
  }

  Logger.log('Total keywords to pause: ' + pauseCount);
  if (CONFIG.DRY_RUN) {
    Logger.log('DRY RUN — no changes applied. Set DRY_RUN = false to execute.');
  }
}
```

### 2. Budget Pacing Monitor

Check daily spend against a monthly target and alert if overspending or underspending.

```javascript
function main() {
  var CONFIG = {
    MONTHLY_BUDGET: 10000,           // Target monthly spend
    ALERT_THRESHOLD_OVER: 0.15,      // Alert if 15% over pace
    ALERT_THRESHOLD_UNDER: 0.20,     // Alert if 20% under pace
    ALERT_EMAIL: 'team@example.com',
    CAMPAIGN_LABEL: 'Budget-Monitor'  // Only monitor labeled campaigns
  };

  var today = new Date();
  var daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
  var dayOfMonth = today.getDate();
  var expectedSpend = (CONFIG.MONTHLY_BUDGET / daysInMonth) * dayOfMonth;

  // Get month-to-date spend
  var firstOfMonth = Utilities.formatDate(
    new Date(today.getFullYear(), today.getMonth(), 1), 'UTC', 'yyyyMMdd');
  var todayStr = Utilities.formatDate(today, 'UTC', 'yyyyMMdd');

  var campaigns = AdsApp.campaigns()
    .withCondition('Status = ENABLED')
    .withCondition('LabelNames CONTAINS_ANY ["' + CONFIG.CAMPAIGN_LABEL + '"]')
    .forDateRange(firstOfMonth, todayStr)
    .get();

  var totalSpend = 0;
  var campaignDetails = [];

  while (campaigns.hasNext()) {
    var campaign = campaigns.next();
    var stats = campaign.getStatsFor(firstOfMonth, todayStr);
    var spend = stats.getCost();
    totalSpend += spend;
    campaignDetails.push({
      name: campaign.getName(),
      spend: spend,
      conversions: stats.getConversions()
    });
  }

  var paceRatio = totalSpend / expectedSpend;
  var projectedSpend = (totalSpend / dayOfMonth) * daysInMonth;
  var status = 'ON PACE';

  if (paceRatio > (1 + CONFIG.ALERT_THRESHOLD_OVER)) {
    status = 'OVERSPENDING';
  } else if (paceRatio < (1 - CONFIG.ALERT_THRESHOLD_UNDER)) {
    status = 'UNDERSPENDING';
  }

  Logger.log('Budget Pacing Report');
  Logger.log('Day ' + dayOfMonth + ' of ' + daysInMonth);
  Logger.log('Expected spend: $' + expectedSpend.toFixed(2));
  Logger.log('Actual spend: $' + totalSpend.toFixed(2));
  Logger.log('Projected month-end: $' + projectedSpend.toFixed(2));
  Logger.log('Status: ' + status);

  if (status !== 'ON PACE') {
    var subject = '[Google Ads] Budget Alert: ' + status;
    var body = 'Budget Pacing Alert\n\n' +
      'Status: ' + status + '\n' +
      'Day ' + dayOfMonth + ' of ' + daysInMonth + '\n' +
      'Monthly budget: $' + CONFIG.MONTHLY_BUDGET.toFixed(2) + '\n' +
      'Expected spend to date: $' + expectedSpend.toFixed(2) + '\n' +
      'Actual spend to date: $' + totalSpend.toFixed(2) + '\n' +
      'Projected month-end spend: $' + projectedSpend.toFixed(2) + '\n\n' +
      'Campaign Breakdown:\n';

    for (var i = 0; i < campaignDetails.length; i++) {
      body += '  ' + campaignDetails[i].name +
        ': $' + campaignDetails[i].spend.toFixed(2) +
        ' (' + campaignDetails[i].conversions + ' conv)\n';
    }

    MailApp.sendEmail(CONFIG.ALERT_EMAIL, subject, body);
    Logger.log('Alert email sent to ' + CONFIG.ALERT_EMAIL);
  }
}
```

### 3. Search Terms Negative Keyword Miner

Find search terms with high spend and low conversions, then auto-add them as negative keywords.

```javascript
function main() {
  var CONFIG = {
    MIN_COST: 50,                    // Min spend before evaluating
    MAX_CONVERSIONS: 0,              // Max conversions to qualify as negative
    MIN_CLICKS: 10,                  // Min clicks for statistical significance
    DATE_RANGE: 'LAST_30_DAYS',
    NEGATIVE_MATCH_TYPE: 'EXACT',    // EXACT, PHRASE, or BROAD
    DRY_RUN: true,
    LOG_SHEET_URL: ''                // Optional: Google Sheet URL for logging
  };

  var query = "SELECT " +
    "search_term_view.search_term, " +
    "campaign.name, " +
    "campaign.resource_name, " +
    "ad_group.name, " +
    "metrics.impressions, " +
    "metrics.clicks, " +
    "metrics.cost_micros, " +
    "metrics.conversions, " +
    "metrics.ctr " +
    "FROM search_term_view " +
    "WHERE campaign.status = 'ENABLED' " +
    "AND metrics.cost_micros > " + (CONFIG.MIN_COST * 1000000) + " " +
    "AND metrics.clicks > " + CONFIG.MIN_CLICKS + " " +
    "AND metrics.conversions <= " + CONFIG.MAX_CONVERSIONS + " " +
    "AND segments.date DURING " + CONFIG.DATE_RANGE;

  var results = AdsApp.search(query);
  var negatives = [];

  while (results.hasNext()) {
    var row = results.next();
    var term = row.searchTermView.searchTerm;
    var cost = row.metrics.costMicros / 1000000;

    negatives.push({
      term: term,
      campaign: row.campaign.name,
      adGroup: row.adGroup.name,
      clicks: row.metrics.clicks,
      cost: cost,
      conversions: row.metrics.conversions
    });

    Logger.log('NEGATIVE: "' + term + '"' +
      ' | Cost: $' + cost.toFixed(2) +
      ' | Clicks: ' + row.metrics.clicks +
      ' | Conv: ' + row.metrics.conversions +
      ' | Campaign: ' + row.campaign.name);

    if (!CONFIG.DRY_RUN) {
      var campaigns = AdsApp.campaigns()
        .withCondition('Name = "' + row.campaign.name + '"')
        .get();

      if (campaigns.hasNext()) {
        var campaign = campaigns.next();
        if (CONFIG.NEGATIVE_MATCH_TYPE === 'EXACT') {
          campaign.createNegativeKeyword('[' + term + ']');
        } else if (CONFIG.NEGATIVE_MATCH_TYPE === 'PHRASE') {
          campaign.createNegativeKeyword('"' + term + '"');
        } else {
          campaign.createNegativeKeyword(term);
        }
      }
    }
  }

  Logger.log('Total negative keyword candidates: ' + negatives.length);

  // Log to Google Sheet if configured
  if (CONFIG.LOG_SHEET_URL && negatives.length > 0) {
    var sheet = SpreadsheetApp.openByUrl(CONFIG.LOG_SHEET_URL).getActiveSheet();
    var timestamp = new Date().toISOString();

    for (var i = 0; i < negatives.length; i++) {
      sheet.appendRow([
        timestamp,
        negatives[i].term,
        negatives[i].campaign,
        negatives[i].adGroup,
        negatives[i].clicks,
        negatives[i].cost,
        negatives[i].conversions,
        CONFIG.DRY_RUN ? 'DRY RUN' : 'ADDED'
      ]);
    }
  }
}
```

### 4. Broken URL Checker

Iterate through ads and sitelinks, check for HTTP errors (404, 500), and pause or alert.

```javascript
function main() {
  var CONFIG = {
    CHECK_ADS: true,
    CHECK_SITELINKS: true,
    ALERT_EMAIL: 'team@example.com',
    PAUSE_BROKEN: false,           // Pause ads with broken URLs
    DATE_RANGE: 'LAST_7_DAYS',
    MIN_IMPRESSIONS: 10            // Only check active ads
  };

  var brokenUrls = [];

  // Check ad final URLs
  if (CONFIG.CHECK_ADS) {
    var ads = AdsApp.ads()
      .withCondition('Status = ENABLED')
      .withCondition('CampaignStatus = ENABLED')
      .withCondition('AdGroupStatus = ENABLED')
      .forDateRange(CONFIG.DATE_RANGE)
      .withCondition('Impressions > ' + CONFIG.MIN_IMPRESSIONS)
      .get();

    while (ads.hasNext()) {
      var ad = ads.next();
      var urls = ad.urls().getFinalUrl();

      if (urls) {
        var status = checkUrl(urls);
        if (status !== 200) {
          brokenUrls.push({
            type: 'Ad',
            campaign: ad.getCampaign().getName(),
            adGroup: ad.getAdGroup().getName(),
            url: urls,
            httpStatus: status,
            entity: ad
          });

          Logger.log('BROKEN [' + status + ']: ' + urls +
            ' | Campaign: ' + ad.getCampaign().getName());

          if (CONFIG.PAUSE_BROKEN) {
            ad.pause();
            Logger.log('  -> Ad paused');
          }
        }
      }
    }
  }

  // Check sitelink URLs
  if (CONFIG.CHECK_SITELINKS) {
    var query = "SELECT " +
      "asset.final_urls, " +
      "asset.name, " +
      "asset.type, " +
      "campaign.name " +
      "FROM asset " +
      "WHERE asset.type = 'SITELINK' ";

    var results = AdsApp.search(query);

    while (results.hasNext()) {
      var row = results.next();
      var assetUrls = row.asset.finalUrls;

      if (assetUrls && assetUrls.length > 0) {
        for (var i = 0; i < assetUrls.length; i++) {
          var status = checkUrl(assetUrls[i]);
          if (status !== 200) {
            brokenUrls.push({
              type: 'Sitelink',
              campaign: row.campaign ? row.campaign.name : 'Account-level',
              url: assetUrls[i],
              httpStatus: status
            });

            Logger.log('BROKEN SITELINK [' + status + ']: ' + assetUrls[i]);
          }
        }
      }
    }
  }

  // Send alert
  if (brokenUrls.length > 0 && CONFIG.ALERT_EMAIL) {
    var subject = '[Google Ads] ' + brokenUrls.length + ' Broken URL(s) Found';
    var body = 'Broken URL Report\n\n';

    for (var j = 0; j < brokenUrls.length; j++) {
      body += brokenUrls[j].type + ' | ' +
        brokenUrls[j].campaign + ' | ' +
        'HTTP ' + brokenUrls[j].httpStatus + ' | ' +
        brokenUrls[j].url + '\n';
    }

    MailApp.sendEmail(CONFIG.ALERT_EMAIL, subject, body);
  }

  Logger.log('Check complete. Broken URLs found: ' + brokenUrls.length);
}

function checkUrl(url) {
  try {
    var response = UrlFetchApp.fetch(url, {
      muteHttpExceptions: true,
      followRedirects: true,
      validateHttpsCertificates: false
    });
    return response.getResponseCode();
  } catch (e) {
    Logger.log('Error checking ' + url + ': ' + e.message);
    return 0;  // Connection error
  }
}
```

### 5. Bid Adjustments by Time of Day (Dayparting)

Adjust bids based on hourly performance data — increase bids during high-converting hours, decrease during low-performing hours.

```javascript
function main() {
  var CONFIG = {
    DATE_RANGE: 'LAST_30_DAYS',
    TARGET_CAMPAIGNS: [],         // Empty = all enabled campaigns
    MAX_BID_MODIFIER: 0.30,       // +30% max
    MIN_BID_MODIFIER: -0.50,      // -50% max
    MIN_CONVERSIONS: 5,           // Min conversions in an hour slot to adjust
    SHEET_URL: ''                 // Optional: log modifiers to sheet
  };

  // Get hourly performance
  var query = "SELECT " +
    "campaign.name, " +
    "campaign.resource_name, " +
    "segments.hour, " +
    "segments.day_of_week, " +
    "metrics.impressions, " +
    "metrics.clicks, " +
    "metrics.conversions, " +
    "metrics.cost_micros, " +
    "metrics.conversions_value " +
    "FROM campaign " +
    "WHERE campaign.status = 'ENABLED' " +
    "AND segments.date DURING " + CONFIG.DATE_RANGE;

  var results = AdsApp.search(query);
  var hourlyData = {};  // { campaignName: { hour: { clicks, conv, cost } } }

  while (results.hasNext()) {
    var row = results.next();
    var name = row.campaign.name;
    var hour = row.segments.hour;

    if (!hourlyData[name]) hourlyData[name] = {};
    if (!hourlyData[name][hour]) {
      hourlyData[name][hour] = { clicks: 0, conversions: 0, cost: 0, convValue: 0 };
    }

    hourlyData[name][hour].clicks += row.metrics.clicks;
    hourlyData[name][hour].conversions += row.metrics.conversions;
    hourlyData[name][hour].cost += row.metrics.costMicros / 1000000;
    hourlyData[name][hour].convValue += row.metrics.conversionsValue || 0;
  }

  // Calculate and apply modifiers
  for (var campaignName in hourlyData) {
    var hours = hourlyData[campaignName];

    // Calculate average conversion rate across all hours
    var totalConv = 0;
    var totalClicks = 0;
    for (var h in hours) {
      totalConv += hours[h].conversions;
      totalClicks += hours[h].clicks;
    }
    var avgCvr = totalClicks > 0 ? totalConv / totalClicks : 0;

    Logger.log('\nCampaign: ' + campaignName + ' | Avg CVR: ' + (avgCvr * 100).toFixed(2) + '%');

    for (var hour = 0; hour < 24; hour++) {
      var data = hours[hour] || { clicks: 0, conversions: 0, cost: 0 };
      var hourCvr = data.clicks > 0 ? data.conversions / data.clicks : 0;

      var modifier = 0;
      if (avgCvr > 0 && data.conversions >= CONFIG.MIN_CONVERSIONS) {
        modifier = (hourCvr / avgCvr) - 1;
        modifier = Math.max(CONFIG.MIN_BID_MODIFIER, Math.min(CONFIG.MAX_BID_MODIFIER, modifier));
      }

      Logger.log('  Hour ' + hour + ':00 | Clicks: ' + data.clicks +
        ' | Conv: ' + data.conversions +
        ' | CVR: ' + (hourCvr * 100).toFixed(2) + '%' +
        ' | Modifier: ' + (modifier > 0 ? '+' : '') + (modifier * 100).toFixed(0) + '%');
    }
  }
}
```

### 6. Quality Score Tracker

Log quality score data to Google Sheets for historical tracking — Google Ads does not retain quality score history natively.

```javascript
function main() {
  var CONFIG = {
    SHEET_URL: 'YOUR_GOOGLE_SHEET_URL',
    SHEET_NAME: 'QS History',
    MIN_IMPRESSIONS: 50,
    DATE_RANGE: 'LAST_7_DAYS'
  };

  var sheet = SpreadsheetApp.openByUrl(CONFIG.SHEET_URL)
    .getSheetByName(CONFIG.SHEET_NAME);

  // Add headers if sheet is empty
  if (sheet.getLastRow() === 0) {
    sheet.appendRow([
      'Date', 'Campaign', 'Ad Group', 'Keyword', 'Match Type',
      'Quality Score', 'Expected CTR', 'Ad Relevance', 'Landing Page Exp',
      'Impressions', 'Clicks', 'Cost', 'Conversions'
    ]);
  }

  var today = Utilities.formatDate(new Date(), 'UTC', 'yyyy-MM-dd');

  var query = "SELECT " +
    "campaign.name, " +
    "ad_group.name, " +
    "ad_group_criterion.keyword.text, " +
    "ad_group_criterion.keyword.match_type, " +
    "ad_group_criterion.quality_info.quality_score, " +
    "ad_group_criterion.quality_info.creative_quality_score, " +
    "ad_group_criterion.quality_info.search_predicted_ctr, " +
    "ad_group_criterion.quality_info.post_click_quality_score, " +
    "metrics.impressions, " +
    "metrics.clicks, " +
    "metrics.cost_micros, " +
    "metrics.conversions " +
    "FROM keyword_view " +
    "WHERE campaign.status = 'ENABLED' " +
    "AND ad_group.status = 'ENABLED' " +
    "AND ad_group_criterion.status = 'ENABLED' " +
    "AND metrics.impressions > " + CONFIG.MIN_IMPRESSIONS + " " +
    "AND segments.date DURING " + CONFIG.DATE_RANGE;

  var results = AdsApp.search(query);
  var rows = [];

  while (results.hasNext()) {
    var row = results.next();
    var qi = row.adGroupCriterion.qualityInfo;

    rows.push([
      today,
      row.campaign.name,
      row.adGroup.name,
      row.adGroupCriterion.keyword.text,
      row.adGroupCriterion.keyword.matchType,
      qi.qualityScore || '',
      qi.searchPredictedCtr || '',
      qi.creativeQualityScore || '',
      qi.postClickQualityScore || '',
      row.metrics.impressions,
      row.metrics.clicks,
      (row.metrics.costMicros / 1000000).toFixed(2),
      row.metrics.conversions
    ]);
  }

  if (rows.length > 0) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length)
      .setValues(rows);
  }

  Logger.log('Logged ' + rows.length + ' keywords to sheet');
}
```

### 7. Ad Copy Testing (RSA Performance)

Compare responsive search ad performance across ad groups and flag winners and losers.

```javascript
function main() {
  var CONFIG = {
    MIN_IMPRESSIONS: 500,
    DATE_RANGE: 'LAST_30_DAYS',
    WINNER_CTR_LIFT: 0.20,       // 20% CTR lift = winner
    LOSER_CTR_DROP: -0.20,       // 20% CTR drop = loser
    ALERT_EMAIL: 'team@example.com'
  };

  var query = "SELECT " +
    "campaign.name, " +
    "ad_group.name, " +
    "ad_group_ad.ad.id, " +
    "ad_group_ad.ad.responsive_search_ad.headlines, " +
    "ad_group_ad.ad.responsive_search_ad.descriptions, " +
    "ad_group_ad.ad_strength, " +
    "metrics.impressions, " +
    "metrics.clicks, " +
    "metrics.ctr, " +
    "metrics.conversions, " +
    "metrics.cost_micros " +
    "FROM ad_group_ad " +
    "WHERE campaign.status = 'ENABLED' " +
    "AND ad_group.status = 'ENABLED' " +
    "AND ad_group_ad.status = 'ENABLED' " +
    "AND ad_group_ad.ad.type = 'RESPONSIVE_SEARCH_AD' " +
    "AND metrics.impressions > " + CONFIG.MIN_IMPRESSIONS + " " +
    "AND segments.date DURING " + CONFIG.DATE_RANGE;

  var results = AdsApp.search(query);

  // Group by ad group
  var adGroups = {};

  while (results.hasNext()) {
    var row = results.next();
    var agKey = row.campaign.name + ' > ' + row.adGroup.name;

    if (!adGroups[agKey]) adGroups[agKey] = [];

    adGroups[agKey].push({
      adId: row.adGroupAd.ad.id,
      strength: row.adGroupAd.adStrength,
      impressions: row.metrics.impressions,
      clicks: row.metrics.clicks,
      ctr: row.metrics.ctr,
      conversions: row.metrics.conversions,
      cost: row.metrics.costMicros / 1000000
    });
  }

  var winners = [];
  var losers = [];

  for (var agKey in adGroups) {
    var ads = adGroups[agKey];
    if (ads.length < 2) continue;  // Need at least 2 ads to compare

    // Calculate ad group average CTR
    var totalClicks = 0;
    var totalImpr = 0;
    for (var i = 0; i < ads.length; i++) {
      totalClicks += ads[i].clicks;
      totalImpr += ads[i].impressions;
    }
    var avgCtr = totalImpr > 0 ? totalClicks / totalImpr : 0;

    for (var j = 0; j < ads.length; j++) {
      var ad = ads[j];
      var ctrLift = avgCtr > 0 ? (ad.ctr - avgCtr) / avgCtr : 0;

      if (ctrLift >= CONFIG.WINNER_CTR_LIFT) {
        winners.push({ adGroup: agKey, ad: ad, lift: ctrLift });
      } else if (ctrLift <= CONFIG.LOSER_CTR_DROP) {
        losers.push({ adGroup: agKey, ad: ad, lift: ctrLift });
      }
    }
  }

  Logger.log('Winners: ' + winners.length + ' | Losers: ' + losers.length);

  if ((winners.length > 0 || losers.length > 0) && CONFIG.ALERT_EMAIL) {
    var body = 'Ad Copy Test Results (' + CONFIG.DATE_RANGE + ')\n\n';

    if (winners.length > 0) {
      body += 'WINNERS (CTR lift > ' + (CONFIG.WINNER_CTR_LIFT * 100) + '%):\n';
      for (var w = 0; w < winners.length; w++) {
        body += '  ' + winners[w].adGroup +
          ' | Ad #' + winners[w].ad.adId +
          ' | CTR: ' + (winners[w].ad.ctr * 100).toFixed(2) + '%' +
          ' | Lift: +' + (winners[w].lift * 100).toFixed(0) + '%\n';
      }
    }

    if (losers.length > 0) {
      body += '\nLOSERS (CTR drop > ' + (Math.abs(CONFIG.LOSER_CTR_DROP) * 100) + '%):\n';
      for (var l = 0; l < losers.length; l++) {
        body += '  ' + losers[l].adGroup +
          ' | Ad #' + losers[l].ad.adId +
          ' | CTR: ' + (losers[l].ad.ctr * 100).toFixed(2) + '%' +
          ' | Drop: ' + (losers[l].lift * 100).toFixed(0) + '%\n';
      }
    }

    MailApp.sendEmail(CONFIG.ALERT_EMAIL, '[Google Ads] Ad Copy Test Results', body);
  }
}
```

### 8. Budget Allocation by ROAS

Shift budget between campaigns based on return on ad spend — feed high performers, starve low performers.

```javascript
function main() {
  var CONFIG = {
    TOTAL_DAILY_BUDGET: 500,     // Total daily budget across campaigns
    MIN_BUDGET: 20,              // Minimum per-campaign daily budget
    MAX_BUDGET: 200,             // Maximum per-campaign daily budget
    DATE_RANGE: 'LAST_14_DAYS',
    CAMPAIGN_LABEL: 'Auto-Budget',
    MIN_CONVERSIONS: 3,          // Min conversions to be eligible for increase
    DRY_RUN: true
  };

  var campaigns = AdsApp.campaigns()
    .withCondition('Status = ENABLED')
    .withCondition('LabelNames CONTAINS_ANY ["' + CONFIG.CAMPAIGN_LABEL + '"]')
    .forDateRange(CONFIG.DATE_RANGE)
    .get();

  var campaignData = [];

  while (campaigns.hasNext()) {
    var campaign = campaigns.next();
    var stats = campaign.getStatsFor(CONFIG.DATE_RANGE);
    var cost = stats.getCost();
    var convValue = stats.getConversionValue();
    var roas = cost > 0 ? convValue / cost : 0;

    campaignData.push({
      campaign: campaign,
      name: campaign.getName(),
      cost: cost,
      convValue: convValue,
      conversions: stats.getConversions(),
      roas: roas,
      currentBudget: campaign.getBudget().getAmount()
    });
  }

  // Sort by ROAS descending
  campaignData.sort(function(a, b) { return b.roas - a.roas; });

  // Calculate budget allocation based on ROAS weighting
  var totalRoas = 0;
  for (var i = 0; i < campaignData.length; i++) {
    if (campaignData[i].conversions >= CONFIG.MIN_CONVERSIONS) {
      totalRoas += campaignData[i].roas;
    }
  }

  var remaining = CONFIG.TOTAL_DAILY_BUDGET;

  for (var j = 0; j < campaignData.length; j++) {
    var c = campaignData[j];
    var newBudget;

    if (c.conversions >= CONFIG.MIN_CONVERSIONS && totalRoas > 0) {
      newBudget = (c.roas / totalRoas) * CONFIG.TOTAL_DAILY_BUDGET;
    } else {
      newBudget = CONFIG.MIN_BUDGET;
    }

    newBudget = Math.max(CONFIG.MIN_BUDGET, Math.min(CONFIG.MAX_BUDGET, newBudget));
    newBudget = Math.round(newBudget * 100) / 100;

    Logger.log(c.name +
      ' | ROAS: ' + c.roas.toFixed(2) +
      ' | Current: $' + c.currentBudget.toFixed(2) +
      ' | New: $' + newBudget.toFixed(2) +
      ' | Change: ' + (newBudget > c.currentBudget ? '+' : '') +
        '$' + (newBudget - c.currentBudget).toFixed(2));

    if (!CONFIG.DRY_RUN) {
      c.campaign.getBudget().setAmount(newBudget);
    }
  }

  if (CONFIG.DRY_RUN) {
    Logger.log('\nDRY RUN — no budgets changed.');
  }
}
```

### 9. Anomaly Detection

Alert on sudden drops or spikes in impressions, clicks, cost, or conversions compared to historical averages.

```javascript
function main() {
  var CONFIG = {
    LOOKBACK_DAYS: 14,           // Compare against this many days
    SPIKE_THRESHOLD: 2.0,        // Alert if 2x the average
    DROP_THRESHOLD: 0.5,         // Alert if below 50% of average
    ALERT_EMAIL: 'team@example.com',
    METRICS: ['impressions', 'clicks', 'cost_micros', 'conversions']
  };

  var today = new Date();
  var yesterday = new Date(today.getTime() - 86400000);
  var lookbackStart = new Date(today.getTime() - (CONFIG.LOOKBACK_DAYS + 1) * 86400000);

  var yesterdayStr = Utilities.formatDate(yesterday, 'UTC', 'yyyyMMdd');
  var lookbackStr = Utilities.formatDate(lookbackStart, 'UTC', 'yyyyMMdd');

  // Get yesterday's data
  var query = "SELECT " +
    "campaign.name, " +
    "metrics.impressions, " +
    "metrics.clicks, " +
    "metrics.cost_micros, " +
    "metrics.conversions, " +
    "segments.date " +
    "FROM campaign " +
    "WHERE campaign.status = 'ENABLED' " +
    "AND segments.date BETWEEN '" +
      lookbackStr.substring(0, 4) + '-' + lookbackStr.substring(4, 6) + '-' + lookbackStr.substring(6, 8) +
    "' AND '" +
      yesterdayStr.substring(0, 4) + '-' + yesterdayStr.substring(4, 6) + '-' + yesterdayStr.substring(6, 8) + "'";

  var results = AdsApp.search(query);

  // Aggregate by campaign and date
  var campaignDays = {};  // { name: { date: { impressions, clicks, ... } } }

  while (results.hasNext()) {
    var row = results.next();
    var name = row.campaign.name;
    var date = row.segments.date;

    if (!campaignDays[name]) campaignDays[name] = {};
    campaignDays[name][date] = {
      impressions: row.metrics.impressions,
      clicks: row.metrics.clicks,
      cost_micros: row.metrics.costMicros,
      conversions: row.metrics.conversions
    };
  }

  var alerts = [];
  var yesterdayDate = Utilities.formatDate(yesterday, 'UTC', 'yyyy-MM-dd');

  for (var campaignName in campaignDays) {
    var days = campaignDays[campaignName];
    var yesterdayData = days[yesterdayDate];
    if (!yesterdayData) continue;

    for (var m = 0; m < CONFIG.METRICS.length; m++) {
      var metric = CONFIG.METRICS[m];

      // Calculate historical average (excluding yesterday)
      var sum = 0;
      var count = 0;
      for (var d in days) {
        if (d !== yesterdayDate) {
          sum += days[d][metric] || 0;
          count++;
        }
      }

      if (count === 0) continue;
      var avg = sum / count;
      var current = yesterdayData[metric] || 0;

      if (avg > 0) {
        var ratio = current / avg;

        if (ratio >= CONFIG.SPIKE_THRESHOLD) {
          alerts.push({
            campaign: campaignName,
            metric: metric,
            type: 'SPIKE',
            current: current,
            average: avg,
            ratio: ratio
          });
        } else if (ratio <= CONFIG.DROP_THRESHOLD) {
          alerts.push({
            campaign: campaignName,
            metric: metric,
            type: 'DROP',
            current: current,
            average: avg,
            ratio: ratio
          });
        }
      }
    }
  }

  if (alerts.length > 0) {
    Logger.log('ANOMALIES DETECTED: ' + alerts.length);
    var body = 'Google Ads Anomaly Report — ' + yesterdayDate + '\n\n';

    for (var a = 0; a < alerts.length; a++) {
      var alert = alerts[a];
      var metricLabel = alert.metric.replace('_micros', '').replace('_', ' ');
      var currentVal = alert.metric === 'cost_micros'
        ? '$' + (alert.current / 1000000).toFixed(2)
        : alert.current.toFixed(0);
      var avgVal = alert.metric === 'cost_micros'
        ? '$' + (alert.average / 1000000).toFixed(2)
        : alert.average.toFixed(0);

      body += alert.type + ': ' + alert.campaign +
        ' | ' + metricLabel +
        ' | Yesterday: ' + currentVal +
        ' | Avg: ' + avgVal +
        ' | Ratio: ' + alert.ratio.toFixed(2) + 'x\n';

      Logger.log(body);
    }

    MailApp.sendEmail(CONFIG.ALERT_EMAIL, '[Google Ads] ' + alerts.length + ' Anomalies Detected', body);
  } else {
    Logger.log('No anomalies detected.');
  }
}
```

### 10. Landing Page Performance

Cross-reference landing page URLs with conversion data to find underperforming pages.

```javascript
function main() {
  var CONFIG = {
    DATE_RANGE: 'LAST_30_DAYS',
    MIN_CLICKS: 50,
    SHEET_URL: 'YOUR_GOOGLE_SHEET_URL',
    SHEET_NAME: 'Landing Pages'
  };

  var query = "SELECT " +
    "landing_page_view.unexpanded_final_url, " +
    "metrics.clicks, " +
    "metrics.impressions, " +
    "metrics.cost_micros, " +
    "metrics.conversions, " +
    "metrics.conversions_value, " +
    "metrics.bounce_rate " +
    "FROM landing_page_view " +
    "WHERE segments.date DURING " + CONFIG.DATE_RANGE + " " +
    "AND metrics.clicks > " + CONFIG.MIN_CLICKS + " " +
    "ORDER BY metrics.clicks DESC";

  var results = AdsApp.search(query);
  var pages = [];

  while (results.hasNext()) {
    var row = results.next();
    var cost = row.metrics.costMicros / 1000000;
    var cvr = row.metrics.clicks > 0
      ? row.metrics.conversions / row.metrics.clicks
      : 0;
    var roas = cost > 0
      ? row.metrics.conversionsValue / cost
      : 0;

    pages.push({
      url: row.landingPageView.unexpandedFinalUrl,
      clicks: row.metrics.clicks,
      impressions: row.metrics.impressions,
      cost: cost,
      conversions: row.metrics.conversions,
      convValue: row.metrics.conversionsValue,
      cvr: cvr,
      roas: roas,
      bounceRate: row.metrics.bounceRate || 0
    });

    Logger.log(row.landingPageView.unexpandedFinalUrl +
      ' | Clicks: ' + row.metrics.clicks +
      ' | CVR: ' + (cvr * 100).toFixed(2) + '%' +
      ' | ROAS: ' + roas.toFixed(2));
  }

  // Export to sheet
  if (CONFIG.SHEET_URL && pages.length > 0) {
    var sheet = SpreadsheetApp.openByUrl(CONFIG.SHEET_URL)
      .getSheetByName(CONFIG.SHEET_NAME);

    sheet.clear();
    sheet.appendRow([
      'URL', 'Clicks', 'Impressions', 'Cost', 'Conversions',
      'Conv Value', 'CVR', 'ROAS', 'Bounce Rate'
    ]);

    for (var i = 0; i < pages.length; i++) {
      var p = pages[i];
      sheet.appendRow([
        p.url, p.clicks, p.impressions,
        p.cost.toFixed(2), p.conversions,
        p.convValue.toFixed(2),
        (p.cvr * 100).toFixed(2) + '%',
        p.roas.toFixed(2),
        (p.bounceRate * 100).toFixed(2) + '%'
      ]);
    }

    Logger.log('Exported ' + pages.length + ' landing pages to sheet');
  }
}
```

## Google Sheets Integration

Google Ads Scripts can read from and write to Google Sheets using `SpreadsheetApp`. This is the most common way to build dashboards, log data, and configure scripts dynamically.

### Opening a Spreadsheet

```javascript
// By URL (recommended — works across accounts)
var ss = SpreadsheetApp.openByUrl('https://docs.google.com/spreadsheets/d/SHEET_ID/edit');

// By ID
var ss = SpreadsheetApp.openById('SHEET_ID');

// Access a specific sheet tab
var sheet = ss.getSheetByName('Data');
var activeSheet = ss.getActiveSheet();
```

### Reading Data

```javascript
// Read a specific range
var range = sheet.getRange('A1:D10');
var values = range.getValues();  // Returns 2D array

// Read all data
var allData = sheet.getDataRange().getValues();

// Iterate rows
for (var i = 1; i < allData.length; i++) {  // Skip header row
  var campaign = allData[i][0];
  var target = allData[i][1];
  Logger.log(campaign + ': ' + target);
}

// Read a single cell
var value = sheet.getRange('B2').getValue();
```

### Writing Data

```javascript
// Write a single value
sheet.getRange('A1').setValue('Campaign Name');

// Write a row
sheet.appendRow(['2025-02-11', 'Search - Brand', 150, 12, 3]);

// Write a 2D array (batch — much faster)
var data = [
  ['Campaign', 'Clicks', 'Cost', 'Conv'],
  ['Brand', 150, 45.00, 12],
  ['Generic', 300, 120.00, 8]
];
sheet.getRange(1, 1, data.length, data[0].length).setValues(data);

// Clear a sheet
sheet.clear();
```

### Building an Automated Dashboard

```javascript
function main() {
  var ss = SpreadsheetApp.openByUrl('YOUR_SHEET_URL');
  var sheet = ss.getSheetByName('Dashboard');
  sheet.clear();

  // Headers
  sheet.appendRow([
    'Campaign', 'Status', 'Impressions', 'Clicks', 'CTR',
    'Cost', 'Conversions', 'CPA', 'ROAS', 'Updated'
  ]);

  var campaigns = AdsApp.campaigns()
    .withCondition('Status = ENABLED')
    .forDateRange('LAST_30_DAYS')
    .orderBy('Cost DESC')
    .get();

  var rows = [];
  while (campaigns.hasNext()) {
    var campaign = campaigns.next();
    var stats = campaign.getStatsFor('LAST_30_DAYS');
    var cost = stats.getCost();

    rows.push([
      campaign.getName(),
      campaign.isEnabled() ? 'Enabled' : 'Paused',
      stats.getImpressions(),
      stats.getClicks(),
      (stats.getCtr() * 100).toFixed(2) + '%',
      '$' + cost.toFixed(2),
      stats.getConversions(),
      stats.getConversions() > 0 ? '$' + (cost / stats.getConversions()).toFixed(2) : 'N/A',
      cost > 0 ? (stats.getConversionValue() / cost).toFixed(2) : 'N/A',
      new Date().toISOString()
    ]);
  }

  if (rows.length > 0) {
    sheet.getRange(2, 1, rows.length, rows[0].length).setValues(rows);
  }
}
```

## Email and Notifications

### MailApp.sendEmail()

```javascript
// Simple text email
MailApp.sendEmail('team@example.com', 'Subject Line', 'Body text here');

// Email with options
MailApp.sendEmail({
  to: 'team@example.com',
  cc: 'manager@example.com',
  subject: '[Google Ads] Weekly Report',
  body: 'Plain text fallback',
  htmlBody: '<h1>Weekly Report</h1><p>See details below.</p>'
});
```

### HTML Email Template

```javascript
function buildAlertEmail(alerts) {
  var html = '<html><body style="font-family: Arial, sans-serif;">';
  html += '<h2 style="color: #d93025;">Google Ads Alert</h2>';
  html += '<table style="border-collapse: collapse; width: 100%;">';
  html += '<tr style="background: #f1f3f4;">' +
    '<th style="padding: 8px; text-align: left;">Campaign</th>' +
    '<th style="padding: 8px; text-align: left;">Metric</th>' +
    '<th style="padding: 8px; text-align: right;">Value</th>' +
    '<th style="padding: 8px; text-align: right;">Threshold</th>' +
    '</tr>';

  for (var i = 0; i < alerts.length; i++) {
    var bgColor = i % 2 === 0 ? '#ffffff' : '#f9f9f9';
    html += '<tr style="background: ' + bgColor + ';">' +
      '<td style="padding: 8px;">' + alerts[i].campaign + '</td>' +
      '<td style="padding: 8px;">' + alerts[i].metric + '</td>' +
      '<td style="padding: 8px; text-align: right;">' + alerts[i].value + '</td>' +
      '<td style="padding: 8px; text-align: right;">' + alerts[i].threshold + '</td>' +
      '</tr>';
  }

  html += '</table>';
  html += '<p style="color: #666; font-size: 12px;">Generated by Google Ads Scripts</p>';
  html += '</body></html>';

  return html;
}

// Usage
MailApp.sendEmail({
  to: 'team@example.com',
  subject: '[Google Ads] ' + alerts.length + ' Alerts',
  htmlBody: buildAlertEmail(alerts)
});
```

### Alert Threshold Pattern

```javascript
function checkAndAlert(metricName, currentValue, threshold, direction) {
  var triggered = false;

  if (direction === 'above' && currentValue > threshold) {
    triggered = true;
  } else if (direction === 'below' && currentValue < threshold) {
    triggered = true;
  }

  if (triggered) {
    Logger.log('ALERT: ' + metricName + ' = ' + currentValue + ' (threshold: ' + threshold + ')');
  }

  return triggered;
}
```

## Scheduling

Google Ads Scripts can be scheduled to run automatically from the Google Ads interface.

### Available Frequencies

| Frequency | When it runs |
|-----------|-------------|
| Hourly | Every hour on the hour |
| Daily | Once per day at your chosen time |
| Weekly | Once per week on your chosen day and time |
| Monthly | Once per month on your chosen day and time |

### Setting Up a Schedule

1. Navigate to **Tools & Settings > Bulk Actions > Scripts**
2. Select or create your script
3. Click the **pencil icon** next to "Frequency"
4. Choose the frequency and time
5. Click **Save**

### Scheduling Best Practices

- **Budget pacing**: Run daily or hourly
- **Negative keyword mining**: Run weekly
- **Quality score tracking**: Run daily
- **Anomaly detection**: Run daily (morning)
- **Broken URL checks**: Run weekly
- **Performance dashboards**: Run daily
- **Bid adjustments**: Run daily after enough data accumulates

## Error Handling

### try/catch Pattern

```javascript
function main() {
  try {
    processKeywords();
  } catch (e) {
    Logger.log('ERROR: ' + e.message);
    Logger.log('Stack: ' + e.stack);

    // Send error notification
    MailApp.sendEmail(
      'team@example.com',
      '[Google Ads Script] Error in Keyword Processor',
      'Error: ' + e.message + '\n\nStack trace:\n' + e.stack
    );
  }
}

function processKeywords() {
  var keywords = AdsApp.keywords()
    .withCondition('Status = ENABLED')
    .forDateRange('LAST_7_DAYS')
    .get();

  var processed = 0;
  var errors = 0;

  while (keywords.hasNext()) {
    try {
      var keyword = keywords.next();
      // Process keyword...
      processed++;
    } catch (e) {
      errors++;
      Logger.log('Error processing keyword: ' + e.message);
    }
  }

  Logger.log('Processed: ' + processed + ' | Errors: ' + errors);
}
```

### Logging

```javascript
// Basic logging
Logger.log('Message here');

// Structured logging pattern
function log(level, message, data) {
  var entry = '[' + level + '] ' + message;
  if (data) {
    entry += ' | ' + JSON.stringify(data);
  }
  Logger.log(entry);
}

log('INFO', 'Script started');
log('WARN', 'Low impressions', { campaign: 'Brand', impressions: 5 });
log('ERROR', 'Failed to update bid', { keyword: 'buy shoes', error: 'Budget exceeded' });
```

### Execution Time Limits

| Script Type | Time Limit |
|-------------|-----------|
| Single-account | 30 minutes |
| MCC script | 60 minutes |

**Handling long-running scripts:**

```javascript
function main() {
  var startTime = new Date().getTime();
  var MAX_RUNTIME = 25 * 60 * 1000;  // 25 minutes (5 min buffer)

  var keywords = AdsApp.keywords()
    .withCondition('Status = ENABLED')
    .get();

  while (keywords.hasNext()) {
    // Check remaining time
    if (new Date().getTime() - startTime > MAX_RUNTIME) {
      Logger.log('Approaching time limit. Stopping gracefully.');
      break;
    }

    var keyword = keywords.next();
    // Process...
  }
}
```

## MCC Scripts

MCC (My Client Center) scripts operate across multiple accounts using `AdsManagerApp`.

### Basic MCC Script

```javascript
function main() {
  var accounts = AdsManagerApp.accounts()
    .withCondition('Impressions > 0')
    .forDateRange('LAST_7_DAYS')
    .withLimit(50)
    .get();

  while (accounts.hasNext()) {
    var account = accounts.next();
    AdsManagerApp.select(account);

    Logger.log('Processing: ' + account.getName() + ' (' + account.getCustomerId() + ')');

    var campaigns = AdsApp.campaigns()
      .withCondition('Status = ENABLED')
      .forDateRange('LAST_7_DAYS')
      .get();

    while (campaigns.hasNext()) {
      var campaign = campaigns.next();
      var stats = campaign.getStatsFor('LAST_7_DAYS');
      Logger.log('  ' + campaign.getName() + ': ' + stats.getClicks() + ' clicks');
    }
  }
}
```

### Parallel Processing with executeInParallel()

Process multiple accounts simultaneously for much faster execution.

```javascript
function main() {
  var accounts = AdsManagerApp.accounts()
    .withCondition('Impressions > 0')
    .forDateRange('LAST_7_DAYS')
    .get();

  // Execute processAccount() on each account in parallel
  // Results are collected by aggregateResults()
  accounts.executeInParallel('processAccount', 'aggregateResults');
}

// Runs on each account (has access to AdsApp for that account)
function processAccount() {
  var account = AdsApp.currentAccount();
  var result = {
    accountName: account.getName(),
    accountId: account.getCustomerId(),
    campaigns: []
  };

  var campaigns = AdsApp.campaigns()
    .withCondition('Status = ENABLED')
    .forDateRange('LAST_7_DAYS')
    .get();

  while (campaigns.hasNext()) {
    var campaign = campaigns.next();
    var stats = campaign.getStatsFor('LAST_7_DAYS');

    result.campaigns.push({
      name: campaign.getName(),
      clicks: stats.getClicks(),
      cost: stats.getCost(),
      conversions: stats.getConversions()
    });
  }

  // Must return a string (JSON)
  return JSON.stringify(result);
}

// Called once after all parallel executions complete
function aggregateResults(results) {
  var allData = [];

  for (var i = 0; i < results.length; i++) {
    if (results[i].getStatus() === 'OK') {
      var data = JSON.parse(results[i].getReturnValue());
      allData.push(data);
      Logger.log('Account: ' + data.accountName + ' — ' + data.campaigns.length + ' campaigns');
    } else {
      Logger.log('Error for account: ' + results[i].getError());
    }
  }

  // Export aggregated data to a sheet, send report, etc.
  Logger.log('Total accounts processed: ' + allData.length);
}
```

### executeInParallel() Rules

- The per-account function receives an optional string argument — pass config as JSON if needed
- The per-account function must return a string (use `JSON.stringify()`)
- The callback function receives an array of `ExecutionResult` objects
- Each `ExecutionResult` has `.getStatus()` (`'OK'` or `'ERROR'`), `.getReturnValue()`, `.getError()`, `.getCustomerId()`
- Maximum 50 accounts per `executeInParallel()` call
- Each account execution has a 30-minute limit

## Best Practices

### 1. Always Use Preview Mode First

Before running any script that modifies your account, use the **Preview** button. This executes the script in read-only mode and shows all Logger.log output without making any changes.

### 2. Log Before Modifying

```javascript
// Log the change BEFORE making it
Logger.log('Pausing keyword: "' + keyword.getText() + '"' +
  ' in campaign: ' + keyword.getCampaign().getName() +
  ' | Reason: QS = ' + qs + ', CTR = ' + (ctr * 100).toFixed(2) + '%');

keyword.pause();
```

### 3. Use a DRY_RUN Config Flag

```javascript
var CONFIG = {
  DRY_RUN: true  // Set to false only after verifying Preview output
};

// In your modification code:
if (!CONFIG.DRY_RUN) {
  keyword.pause();
}
```

### 4. Incremental Rollout

Start with narrow targeting (one campaign, one ad group) and expand:

```javascript
var campaigns = AdsApp.campaigns()
  .withCondition('Name = "Search - Brand"')  // Start narrow
  // .withCondition('Status = ENABLED')       // Expand later
  .get();
```

### 5. Always Send Error Notifications

Do not rely on checking script logs manually. Send email alerts when scripts fail or detect anomalies.

### 6. Document Your Scripts

Add a comment block at the top of every script:

```javascript
/**
 * Script: Budget Pacing Monitor
 * Purpose: Alerts when monthly spend is off pace
 * Schedule: Daily at 9:00 AM
 * Author: team@example.com
 * Last modified: 2025-02-11
 *
 * Config:
 *   MONTHLY_BUDGET - Target monthly spend
 *   ALERT_THRESHOLD_OVER - % over pace before alert
 *   ALERT_EMAIL - Alert recipient
 */
```

### 7. Use Labels for Script Targeting

Instead of hardcoding campaign names, use labels:

```javascript
// Add label "Auto-Bid" to campaigns in the UI
var campaigns = AdsApp.campaigns()
  .withCondition('LabelNames CONTAINS_ANY ["Auto-Bid"]')
  .get();
```

### 8. Batch Writes with Arrays

Writing row-by-row to Google Sheets is slow. Batch your writes:

```javascript
// Slow: one API call per row
for (var i = 0; i < data.length; i++) {
  sheet.appendRow(data[i]);
}

// Fast: one API call for all rows
sheet.getRange(2, 1, data.length, data[0].length).setValues(data);
```

## Limitations

| Limitation | Details |
|-----------|---------|
| Execution time | 30 minutes (single account), 60 minutes (MCC) |
| No async/await | Scripts run synchronously |
| Limited ES6 | No `import`/`export`, no `class`, limited destructuring |
| No Display/Video APIs | Limited support for Display and Video campaigns via scripts |
| No Performance Max | PMax campaigns have read-only access (limited reporting) |
| URL fetch | 50 MB response limit, 60-second timeout per request |
| MailApp | 100 recipients per day, email body size limits |
| SpreadsheetApp | Read/write limits apply (cells per operation) |
| No npm/modules | Cannot import external libraries |
| Account limits | executeInParallel() caps at 50 accounts per call |

## Claude Code Skill

This reference is also available as a free Claude Code skill — use it directly in your terminal:

```bash
# Install
curl -sSL https://raw.githubusercontent.com/cognyai/claude-code-marketing-skills/main/install.sh | bash

# Use
/google-ads-scripts                          # Full overview
/google-ads-scripts budget pacing            # Budget pacing monitor script
/google-ads-scripts negative keywords        # Search term negative keyword miner
/google-ads-scripts MCC parallel             # MCC parallel processing pattern
```

[View on GitHub ->](https://github.com/cognyai/claude-code-marketing-skills/tree/main/skills/google-ads-scripts)

## Resources

- **Google Ads Scripts Documentation:** [developers.google.com/google-ads/scripts/docs/overview](https://developers.google.com/google-ads/scripts/docs/overview)
- **AdsApp Reference:** [developers.google.com/google-ads/scripts/docs/reference/adsapp/adsapp](https://developers.google.com/google-ads/scripts/docs/reference/adsapp/adsapp)
- **GAQL Reference:** [developers.google.com/google-ads/api/docs/query/overview](https://developers.google.com/google-ads/api/docs/query/overview)
- **Google Ads Scripts Examples:** [developers.google.com/google-ads/scripts/docs/solutions](https://developers.google.com/google-ads/scripts/docs/solutions)
- **Claude Code Marketing Skills:** [github.com/cognyai/claude-code-marketing-skills](https://github.com/cognyai/claude-code-marketing-skills)

---

In this section:

- Conversion Tracking Debugger Reference: https://cogny.com/docs/conversion-tracking-debugger
- Core Web Vitals Reference: https://cogny.com/docs/core-web-vitals
- GA4 BigQuery Export Schema Reference: https://cogny.com/docs/ga4-bigquery-export-schema
- GA4 Event Implementation Reference: https://cogny.com/docs/ga4-event-implementation
- Google Ads Query Language (GAQL) Reference: https://cogny.com/docs/gaql-reference
- **Google Ads Scripts Reference** (this page): https://cogny.com/docs/google-ads-scripts
- GTM Event Tracking & Setup Reference: https://cogny.com/docs/gtm-event-tracking
- Meta Conversions API (CAPI) Setup Reference: https://cogny.com/docs/meta-conversions-api
- Schema.org Structured Data Reference: https://cogny.com/docs/structured-data-reference
- UTM Parameter Strategy & Builder Reference: https://cogny.com/docs/utm-strategy

Source page: https://cogny.com/docs/google-ads-scripts  
All documentation: https://cogny.com/docs  
Cogny for agents: https://cogny.com/llms.txt
