← Back to Documentation
    DocumentationintegrationsFeb 11, 2025

    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.

    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:

    FeatureSingle-Account ScriptMCC Script
    ScopeOne Google Ads accountAll accounts under an MCC
    Entry pointmain()main() with AdsManagerApp
    Execution limit30 minutes60 minutes
    Parallel executionNoYes, via executeInParallel()
    Access objectAdsAppAdsManagerApp + 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:

    // 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

    // 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 [].

    // 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.

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

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

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

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

    Iterator Pattern

    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.

    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.

    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

    FeatureAdsApp.search()AdsApp.report()
    Return typeRow iteratorReport object
    Field accessrow.campaign.nameRow iterator or exportToSheet()
    Sheet exportManualBuilt-in exportToSheet()
    Best forProcessing rows individuallyBulk export, dashboards

    Common Script Patterns

    1. Pause Low-Performing Keywords

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

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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.

    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

    // 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

    // 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

    // 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

    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()

    // 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

    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

    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

    FrequencyWhen it runs
    HourlyEvery hour on the hour
    DailyOnce per day at your chosen time
    WeeklyOnce per week on your chosen day and time
    MonthlyOnce 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

    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

    // 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 TypeTime Limit
    Single-account30 minutes
    MCC script60 minutes

    Handling long-running scripts:

    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

    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.

    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

    // 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

    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:

    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:

    /**
     * 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:

    // 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:

    // 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

    LimitationDetails
    Execution time30 minutes (single account), 60 minutes (MCC)
    No async/awaitScripts run synchronously
    Limited ES6No import/export, no class, limited destructuring
    No Display/Video APIsLimited support for Display and Video campaigns via scripts
    No Performance MaxPMax campaigns have read-only access (limited reporting)
    URL fetch50 MB response limit, 60-second timeout per request
    MailApp100 recipients per day, email body size limits
    SpreadsheetAppRead/write limits apply (cells per operation)
    No npm/modulesCannot import external libraries
    Account limitsexecuteInParallel() 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:

    # 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 ->

    Resources

    Ready for similar results?
    Start with Solo at $9/mo or talk to us about Cloud.
    ❯ get startedcompare plans →