Bid Testing

Bidding icon

When determining the best bid for your keywords, it can be effective to try different levels of bidding to determine which bids work best to achieve your goals. Here we show how to systematically adjust your bids to find the "sweet spot" for your keyword bids.

This script adjusts your keyword bids based on a series of multipliers and record the results of each change.

How it works

This script uses a Google Spreadsheet both to store state (for example, the bid multipliers and starting bids) as well as catalog performance (for each interval of bid testing, it records the keyword bid, CTR, clicks, and impressions).

The bid multipliers are then applied successively for each iteration of the script. Each run applies the next unused bid multiplier to your keyword bids. For example, a starting bid of $1 and multipliers of .8 and 1.2 would see bids of $0.80 and $1.20.

Bid updates

The updateBids() function applies the multiplier for this iteration to all keywords in the campaign you chose:

const keywordIter = campaign.keywords().get();
for (const keyword of keywordIter) {
  let oldBid = startingBids[keyword.getText()];
  if (!oldBid) {
    // If we don't have a starting bid, keyword has been added since we
    // started testing.
    oldBid = keyword.bidding().getCpc() || keyword.getAdGroup().bidding().getCpc();
    startingBids[keyword.getText()] = oldBid;
  }
  const newBid = oldBid * multiplier;
  keyword.bidding().setCpc(newBid);
}

This code uses the keyword's max CPC bid (or the ad group's default max CPC bid if the keyword has no bid) and applies the multiplier. It also detects if a keyword has been added between script executions and stores the current max CPC bid for future reference.

Bid performance reports

Each time the script runs and bids have been applied for at least one time period (week, day, and so on), the outputReport function executes. The date marked on the Multipliers tab and today's date are used as the date range in obtaining metrics for each keyword. The metrics along with the keyword bid, for the period defined by the date range, are stored in a separate sheet for later analysis.

// Create a new sheet to output keywords to.
const reportSheet = spreadsheet.insertSheet(start + ' - ' + end);
const campaign = getCampaign();

const rows = [['Keyword', 'Max CPC', 'Clicks', 'Impressions', 'Ctr']];
const keywordIter = campaign.keywords().get();
for (const keyword of keywordIter) {
  const stats = keyword.getStatsFor(start, end);
  rows.push([keyword.getText(), keyword.bidding().getCpc(), stats.getClicks(),
      stats.getImpressions(), stats.getCtr()]);
}

reportSheet.getRange(1, 1, rows.length, 5).setValues(rows);

Each keyword in the campaign is iterated over and new rows added containing an array of Keyword, Max CPC, Clicks, Impressions, and CTR. This array then gets written to the new sheet (which is named according to the start and end dates of the report).

If you use other key performance indicators than those in the script, you can alter this logic to include them. To do this, add another entry in the first row (header row) like this:

const rows = [['Keyword', 'Max CPC', 'Clicks', 'Impressions', 'Ctr']];

And, modify the script to also include this metric:

rows.push([keyword.getText(), keyword.bidding().getCpc(), stats.getClicks(),
    stats.getImpressions(), stats.getCtr()]);

Scheduling

We recommend you pick a campaign to test the script, and schedule the script to run Weekly (or on any other schedule that suits you). At the scheduled interval, the script reports on the previous period's performance (if applicable) and applies a new bid multiplier.

The main function contains the logic that manages each step of your bid testing. Once bid testing is complete, the script logs this fact and future executions won't make any additional changes:

if (finishedReporting) {
  console.log('Script complete, all bid modifiers tested and reporting. ' +
    'Please remove this script\'s schedule.');
}

Test results

After the script has applied an array of bid modifiers to your keywords and logged the performance of these values across all keywords, it's time to decide what to do with this data. Keep in mind that the same bid modifier might not produce the same performance benefit for all keywords.

Both the keyword's max CPC bid as well as various performance indicators are available for you when determining the most important metrics. You can then evaluate each keyword across multiple intervals and use your own judgment to decide on the best bid for each keyword.

Setup

  • Make a copy of this spreadsheet.
  • Note the URL of the copy.
  • Create a new Google Ads script with the source code below.
  • Change the value of the SPREADSHEET_URL variable to be the URL of your copy of the spreadsheet.
  • Select a campaign to perform bid testing on and change the value of the CAMPAIGN_NAME variable to match it.

Source code

// Copyright 2015, Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @name Bid Testing
 *
 * @overview The Bid Testing script allows you to try different levels of
 *     bidding for keywords in your advertiser account to determine what bids
 *     work best to achieve your goals.
 *     See https://developers.google.com/google-ads/scripts/docs/solutions/bid-testing
 *     for more details.
 *
 * @author Google Ads Scripts Team [adwords-scripts@googlegroups.com]
 *
 * @version 2.0
 *
 * @changelog
 * - version 2.0
 *   - Updated to use new Google Ads scripts features.
 * - version 1.0.3
 *   - Replaced deprecated keyword.setMaxCpc() and keyword.getMaxCpc().
 * - version 1.0.2
 *   - Added validation for user settings.
 * - version 1.0.1
 *   - Improvements to time zone handling.
 * - version 1.0
 *   - Released initial version.
 */

const SPREADSHEET_URL = 'YOUR_SPREADSHEET_URL';
const CAMPAIGN_NAME = 'YOUR_CAMPAIGN_NAME';

const FIELDS = ['ad_group_criterion.keyword.text',
                'ad_group.cpc_bid_micros',
                'metrics.impressions',
                'metrics.clicks',
                'metrics.ctr',
                'campaign.id'];

function main() {
  validateCampaignName();
  console.log(`Using spreadsheet - ${SPREADSHEET_URL}`);
  const spreadsheet = validateAndGetSpreadsheet(SPREADSHEET_URL);
  spreadsheet.setSpreadsheetTimeZone(AdsApp.currentAccount().getTimeZone());

  const multipliersSheet = spreadsheet.getSheetByName('Multipliers');

  const multipliers = multipliersSheet.getDataRange().getValues();
  // Find if we have a multiplier left to apply.
  let multiplierRow = 1;
  for (; multiplierRow < multipliers.length; multiplierRow++) {
    // if we haven't marked a multiplier as applied, use it.
    if (!multipliers[multiplierRow][1]) {
      break;
    }
  }

  const today = Utilities.formatDate(new Date(),
      AdsApp.currentAccount().getTimeZone(), 'yyyyMMdd');

  let shouldReport = multiplierRow > 1;
  let shouldIncreaseBids = multiplierRow < multipliers.length;
  const finishedReporting = multipliersSheet.getSheetProtection().isProtected();

  if (shouldReport && !finishedReporting) {
    // If we have at least one multiplier marked as applied,
    // let's record performance since the last time we ran.
    const lastRun = multipliers[multiplierRow - 1][1];
    if (lastRun == today) {
      console.log('Already ran today, skipping');
      return;
    }
    outputReport(spreadsheet, lastRun, today);

    if (!shouldIncreaseBids) {
      // We've reported one iteration after we finished bids, so mark the sheet
      // protected.
      const permissions = multipliersSheet.getSheetProtection();
      permissions.setProtected(true);
      multipliersSheet.setSheetProtection(permissions);
      console.log(`View bid testing results here: ${SPREADSHEET_URL}`);
    }
  }

  if (shouldIncreaseBids) {
    // If we have a multiplier left to apply, let's do so.
    updateBids(spreadsheet, multipliers[multiplierRow][0]);
    multipliers[multiplierRow][1] = today;
    // Mark multiplier as applied.
    multipliersSheet.getDataRange().setValues(multipliers);
  }

  if (finishedReporting) {
    console.log(`Script complete, all bid modifiers tested and reporting. ` +
      `Please remove this script's schedule.`);
  }
}

function updateBids(spreadsheet, multiplier) {
  console.log(`Applying bid multiplier of ${multiplier}`);

  let startingBids = getStartingBids(spreadsheet);
  if (!startingBids) {
    startingBids = recordStartingBids(spreadsheet);
  }
  const campaign = getCampaign();
  const keywordIter = campaign.keywords().get();
  for (const keyword of keywordIter) {
    let oldBid = startingBids[keyword.getText()];
    if (!oldBid) {
      // If we don't have a starting bid, keyword has been added since we
      // started testing.
      oldBid = keyword.bidding().getCpc() ||
        keyword.getAdGroup().bidding().getCpc();
      startingBids[keyword.getText()] = oldBid;
    }
    const newBid = oldBid * multiplier;
    keyword.bidding().setCpc(newBid);
  }
  saveStartingBids(spreadsheet, startingBids);
}

function outputReport(spreadsheet, start, end) {
  console.log(`Reporting on ${start} -> ${end}`);

  // Create a new sheet to output keywords to.
  const reportSheet = spreadsheet.insertSheet(`${start} - ${end}`);

  const rows = [['Keyword', 'Max CPC', 'Clicks', 'Impressions', 'Ctr']];
  const fields = FIELDS.join(",");
  const keywordIter =
        AdsApp.search(`SELECT ${fields} FROM keyword_view ` +
                      `WHERE campaign.name = '${CAMPAIGN_NAME}' ` +
                      `AND segments.date BETWEEN '${start}' AND '${end}'`);
  for (const keyword of keywordIter) {
    if (keyword.metrics.impressions == 0)
      keyword.metrics.ctr = 0;
    rows.push([keyword.adGroupCriterion.keyword.text,
               keyword.adGroup.cpcBidMicros/1000000,
               keyword.metrics.clicks,
               keyword.metrics.impressions,
               parseFloat(keyword.metrics.ctr).toFixed(5)]);
  }

  reportSheet.getRange(1, 1, rows.length, 5).setValues(rows);
}

function recordStartingBids(spreadsheet) {
  const startingBids = {};
  const keywords = getCampaign().keywords().get();
  for (const keyword of keywords) {
    const bid = keyword.bidding().getCpc() ||
          keyword.getAdGroup().bidding().getCpc();
    startingBids[keyword.getText()] = bid;
  }
  saveStartingBids(spreadsheet, startingBids);
  return startingBids;
}

function getStartingBids(spreadsheet) {
  const sheet = spreadsheet.getSheetByName('Starting Bids');
  if (!sheet) {
    return;
  }
  const rawData = sheet.getDataRange().getValues();
  const startingBids = {};
  for (const i of rawData) {
    startingBids[i[0]] = i[1];
  }
  return startingBids;
}

function saveStartingBids(spreadsheet, startingBids) {
  let sheet = spreadsheet.getSheetByName('Starting Bids');
  if (!sheet) {
    sheet = spreadsheet.insertSheet('Starting Bids');
  }
  const rows = [];
  for (const keyword in startingBids) {
    rows.push([keyword, startingBids[keyword]]);
  }
  sheet.getRange(1, 1, rows.length, 2).setValues(rows);
}

function dateToString(date) {
  return date.getFullYear() + zeroPad(date.getMonth() + 1) +
      zeroPad(date.getDate());
}

function zeroPad(n) {
  if (n < 10) {
    return '0' + n;
  } else {
    return '' + n;
  }
}

function getCampaign() {
  return AdsApp.campaigns().withCondition(`Name = '` +
      `${CAMPAIGN_NAME}'`).get().next();
}

/**
 * Validates the provided campaign name and throws a descriptive error
 * if the user has not changed the email from the default fake name.
 *
 * @throws {Error} If the name is the default fake name.
 */
function validateCampaignName(){
  if (CAMPAIGN_NAME == "YOUR_CAMPAIGN_NAME") {
    throw new Error('Please use a valid campaign name.');
  }
}

/**
 * Validates the provided spreadsheet URL
 * to make sure that it's set up properly. Throws a descriptive error message
 * if validation fails.
 *
 * @param {string} spreadsheeturl The URL of the spreadsheet to open.
 * @return {Spreadsheet} The spreadsheet object itself, fetched from the URL.
 * @throws {Error} If the spreadsheet URL hasn't been set
 */
function validateAndGetSpreadsheet(spreadsheeturl) {
  if (spreadsheeturl == 'YOUR_SPREADSHEET_URL') {
    throw new Error('Please specify a valid Spreadsheet URL. You can find' +
        ' a link to a template in the associated guide for this script.');
  }
  return SpreadsheetApp.openByUrl(spreadsheeturl);
}