Google Ads Script – Gender Bid Adjustment Suggestions

Here’s a question for you:
For each of your Google Ads accounts, do you know (by heart) which gender performs best?

I don’t.

And after doing some analyses, now I know I should.

My analyses showed:

For many clients, Gender performance was sort of the same.
For some, the different Genders performed very differently.
And for a few the difference was very unexpected (and very actionable!).

A client that sells hi-fi audio equipment surprised me the most:
Their Google Ads account targets both generic upper funnel and long-tail lower funnel keywords (in different campaigns).
All the ads had gender-neutral ad copy.
No gender bid adjustments.

I did some analysis of the performance of the different Genders using Google Ads Audience insights and Google Analytics segments.

Turns out, overall the clicks from persons that Google identified as Females show conversion rates that are 1.4x the conversion rate of Male clicks and a ROAS that is 28% higher!

Who would have thought!?

This insight got me to split some campaigns by Gender and to write gender-specific ad copy. Some other campaigns were kept as is, but I’ve added some gender bid adjustments.
(Note, this feature is not supported in Shopping campaigns).

Result: 15% lift in conversions from Females at similar ROAS.

YMMV, but I highly recommend analyzing Gender performance in your accounts.

Insight in Gender performance differences is especially valuable when you are limited by budget; you can simply exclude the worst-performing gender and allocate all budget to the best performing gender, or use gender bid adjustments to steer the budget towards the best performing clicks.

To help you stay up to date on Gender performance in your accounts I’ve created this free Google Ads script.
It monitors the performance of Genders and suggests bid adjustments if the performance data shows a significant difference.

The script creates a report in Google Sheets.
The report lists the ad groups where Genders show a significant difference in Cost per Conversion performance. (You can change the script to use ROAS instead)
The script will also compute and suggest a Gender bid adjustment.

The output will look something like this (click image to enlarge):

Google Ads Gender Bid Adjustment Suggestions
Google Ads Gender Bid Adjustment Suggestions

INSTRUCTIONS:

  1. See the script code below. Install the script in your account.
    Don’t worry if you have never done this before. You do not need any coding skills. It is as simple as copy-paste. Simply follow these instructions on how to set up and schedule google ads scripts.
  2. Create a new Google Sheet
    (tip for chrome users: simply type ‘sheets.new’ in the address bar)
  3. Add the complete URL of the spreadsheet to the script (line 16)
  4. Add your email address to the script (line 43)
  5. Authorize and Preview 
  6. Schedule to run monthly

 


/*** 
*
* Gender Performance - Bid Adjustment Suggestions
*
* This script compares the performance (cost per conversion) of the Genders
* In case the performance difference is greater than the set threshold,
* the issue is logged in a sheet, a suggested bid adjustment is reported and an alert is send via email
*
* @author: Nils Rooijmans
*
* Version 1.0
*
* contact nils@nilsrooijmans.com for questions and a High Performance MCC version of the script 
*/

var SPREADSHEET_URL = ""; //insert a new blank spreadsheet url

var PERIOD = "LAST_90_DAYS"; // the date range can be either "LAST_30_DAYS", "LAST_60_DAYS", "LAST_90_DAYS", or "THIS_YEAR"


/*** THRESHOLDS ***/

var MIN_CLICKS = 100; // Only look at adgroups that have had over this amount of clicks
var MIN_CONVERSIONS = 10; // Only look at adgroups that have had over this amount of conversions
var CPA_DIFF_THRESHOLD = 0.2; // Only send alert when the CPA difference is greater than X%

/*** CAMPAIGN FILTERS ***/

var CAMPAIGN_NAME_CONTAINS = "";
  // Use this if you only want to process some campaigns
  // such as campaigns with names containing 'Brand' or 'Shopping'.
  // Leave as "" if you want to process all campaigns.

var CAMPAIGN_NAME_DOES_NOT_CONTAIN = "";
  // Use this if you want to exclude some campaigns
  // such as campaigns with names containing 'Brand' or 'Shopping'.
  // Leave as "" if not wanted.


/*** EMAIL CONFIGURATION SETTINGS ***/
var SEND_EMAIL = true; // set to false if you do not want to send alert email

var EMAIL_ADDRESS = "";  //insert your email
var EMAIL_SUBJECT = "Gender Performance Difference Alert"; 
var EMAIL_BODY = 
    "\n"+
    "***\n"+
    "\n"+
    "This script compares compares the performance of the Genders:\n"+
    "\n"+
    "For all adgroups that have over "+MIN_CLICKS+" clicks and over "+MIN_CONVERSIONS+" conversions during "+PERIOD+" \n"+
    "		check if the CPA difference between the genders is over "+CPA_DIFF_THRESHOLD*100+"% \n"+
    "		if so, alerts are logged in Google Sheet: "+SPREADSHEET_URL+" \n"+
    "\n"+
    "If there is an alert an email is sent to:\n"+ EMAIL_ADDRESS +"\n---\n\n"+
    "For more FREE Google Ads Scripts to improve your results and make your working day feel like a breeze, visit https://nilsrooijmans.com \n" + 
    "---\n" + 
    "This email is generated by a copy of the free Google Ads Script: Gender Performance - Bid Adjustment Suggestions, (C) Nils Rooijmans \n" +
    "---\n";


var LOG = true;
var DEBUG = true;


function main() {

  var outputSheet = prepareOutputSheet();

  var result = getResults();
  var output = result.output;
  
  if (output.length > 0) {
    debug("output.length: "+output.length);
    addOutputToSheet(output, outputSheet);
  }

  if(output.length > 0 && SEND_EMAIL) {
    var emailSubject = "[Gads Script] "+AdsApp.currentAccount().getName()+" | "+EMAIL_SUBJECT;     
    var emailBody = "\nNumber of adgroups with Gender performance difference: "+output.length+EMAIL_BODY;
    MailApp.sendEmail(EMAIL_ADDRESS, emailSubject, emailBody) ;
  } 
}


function getResults() {

  var date = getTodaysDate();
  var results = [];

  
  // first get all ids from the adgroups that meet our criteria
  var adGroupIds = getAdGroupIds();
 
   
  // second, get all the stats for the ad groups and the genders FEMALE, MALE, UNKNOWN
  var totalStatsObjects = getStatsObjects('TOTAL', adGroupIds); 
  var femaleStatsObjects = getStatsObjects('FEMALE', adGroupIds);
  var maleStatsObjects = getStatsObjects('MALE', adGroupIds);
  var unknownStatsObjects = getStatsObjects('UNDETERMINED', adGroupIds);
      
  
  // third, 
  for (var i=0; i < adGroupIds.length; i++) {
    
    var adGroupId = adGroupIds[i];
    
    var totalStatsObject = getObject(totalStatsObjects, adGroupId);
    
    var femaleStatsObject = getObject(femaleStatsObjects, adGroupId);
    var maleStatsObject = getObject(maleStatsObjects, adGroupId);
    var unknownStatsObject = getObject(unknownStatsObjects, adGroupId);

    var femaleCpaDiff = Math.abs( (femaleStatsObject.costPerConversion-totalStatsObject.costPerConversion) / totalStatsObject.costPerConversion );
    var maleCpaDiff = Math.abs( (maleStatsObject.costPerConversion-totalStatsObject.costPerConversion) / totalStatsObject.costPerConversion );
    var unknownCpaDiff = Math.abs( (unknownStatsObject.costPerConversion-totalStatsObject.costPerConversion) / totalStatsObject.costPerConversion );
    
    
    if ( femaleCpaDiff > CPA_DIFF_THRESHOLD || maleCpaDiff > CPA_DIFF_THRESHOLD || unknownCpaDiff > CPA_DIFF_THRESHOLD ) {    

      var result = [
        date,
        totalStatsObject.campaignName, 
        totalStatsObject.adGroupName,
        "TOTAL",
        totalStatsObject.clicks,
        "100%",
        totalStatsObject.avgCPC.toFixed(2),
        "0%",
        totalStatsObject.cost ,
        "100%",
        totalStatsObject.conversions.toFixed(1),
        "100%",
        totalStatsObject.costPerConversion.toFixed(2),
        "0%",
        "-"
      ];
      results.push(result);
      
      pushResult(femaleStatsObject);
      pushResult(maleStatsObject);
      pushResult(unknownStatsObject);
    }
  }

  
  log("Number of adgroups with Gender performance difference: "+results.length/4);
  
  return { 
    output: results
  };

  
  function pushResult(statsObject) {

    var result = [
      date,
      statsObject.campaignName, 
      statsObject.adGroupName,
      statsObject.gender,
      statsObject.clicks,
      ((statsObject.clicks/totalStatsObject.clicks)*100).toFixed(1)+"%",
      statsObject.avgCPC.toFixed(2),
      (((statsObject.avgCPC-totalStatsObject.avgCPC)/totalStatsObject.avgCPC)*100).toFixed(2)+"%",
      statsObject.cost,
      ((statsObject.cost/totalStatsObject.cost)*100).toFixed(1)+"%",
      statsObject.conversions.toFixed(1),
      ((statsObject.conversions/totalStatsObject.conversions)*100).toFixed(1)+"%",
      statsObject.costPerConversion.toFixed(2),
      (((statsObject.costPerConversion-totalStatsObject.costPerConversion)/totalStatsObject.costPerConversion)*100).toFixed(2)+"%",
      (((totalStatsObject.costPerConversion/statsObject.costPerConversion)-1)*100).toFixed(0)+"%"
    ];
    results.push(result);
  }
}


function getStatsObjects(gender, adGroupIds) {

  var startDate = getStartDate(PERIOD);
  var endDate = getEndDate(PERIOD);
  
  var statsObjects = [];
  
  if (gender == "MALE" || gender == "FEMALE" || gender == "UNDETERMINED" ) {
    var query = 
        "SELECT campaign.name, ad_group.id, ad_group.name, ad_group_criterion.gender.type, metrics.impressions, metrics.clicks, metrics.average_cpc, metrics.cost_micros, metrics.conversions " +
        " FROM gender_view WHERE ad_group.id IN ("+adGroupIds+") and ad_group_criterion.gender.type = '"+gender+"' AND segments.date BETWEEN '" + startDate + "' AND '" + endDate +"'";

    debug(query);

    var result = AdsApp.search(query);

    while (result.hasNext()) {

      var statsObject = {};

      var row = result.next();

      statsObject.campaignName = row.campaign.name;
      statsObject.adGroupName = row.adGroup.name;
      statsObject.adGroupId = row.adGroup.id;
      statsObject.gender = row.adGroupCriterion.gender.type;
      statsObject.impressions = row.metrics.impressions;
      statsObject.clicks = row.metrics.clicks;
      statsObject.ctr = statsObject.clicks/statsObject.impressions;
      statsObject.avgCPC = convertMoney(row.metrics.averageCpc);
      statsObject.cost = convertMoney(row.metrics.costMicros);
      statsObject.conversions = row.metrics.conversions;
      statsObject.conversionRate = statsObject.conversions/statsObject.clicks;
      statsObject.costPerConversion = statsObject.cost/statsObject.conversions;

      statsObjects.push(statsObject);
    }  
  } else if (gender == "TOTAL") {
    var query = 
        "SELECT campaign.name, ad_group.id, ad_group.name, metrics.impressions, metrics.clicks, metrics.average_cpc, metrics.cost_micros, metrics.conversions " +
        " FROM ad_group WHERE ad_group.id IN ("+adGroupIds+") AND segments.date BETWEEN '" + startDate + "' AND '" + endDate +"'";

    debug(query);

    var result = AdsApp.search(query);

    while (result.hasNext()) {

      var statsObject = {};

      var row = result.next();

      statsObject.campaignName = row.campaign.name;
      statsObject.adGroupName = row.adGroup.name;
      statsObject.adGroupId = row.adGroup.id;
      statsObject.gender = "TOTAL";
      statsObject.impressions = row.metrics.impressions;
      statsObject.clicks = row.metrics.clicks;
      statsObject.ctr = statsObject.clicks/statsObject.impressions;
      statsObject.avgCPC = convertMoney(row.metrics.averageCpc);
      statsObject.cost = convertMoney(row.metrics.costMicros);
      statsObject.conversions = row.metrics.conversions;
      statsObject.conversionRate = statsObject.conversions/statsObject.clicks;
      statsObject.costPerConversion = statsObject.cost/statsObject.conversions;

      statsObjects.push(statsObject);
    }  
  }
  
  return statsObjects;
}


function getAdGroupIds() {

  var startDate = getStartDate(PERIOD);
  var endDate = getEndDate(PERIOD);
  
  var whereClause =       
      " WHERE metrics.clicks > " + MIN_CLICKS +
      " AND metrics.conversions > " + MIN_CONVERSIONS;
  
  if (CAMPAIGN_NAME_CONTAINS != "" ) {
    whereClause = whereClause +
      " AND campaign.name LIKE '%" + CAMPAIGN_NAME_CONTAINS + "%'";
  }
  
  if (CAMPAIGN_NAME_DOES_NOT_CONTAIN  != "" ) {
    whereClause = whereClause +
      " AND campaign.name NOT LIKE '%" + CAMPAIGN_NAME_DOES_NOT_CONTAIN  + "%'";
  }
  
  whereClause = whereClause +
      " AND campaign.status = ENABLED" +
      " AND segments.date BETWEEN '" + startDate + "' AND '" + endDate +"'"+
      " ORDER BY metrics.clicks DESC";
  
  var query = 
      "SELECT ad_group.id, ad_group.name, metrics.clicks, metrics.conversions " +
      " FROM ad_group" + whereClause;
  
  debug(query);
  
  var adGroupIds = [];
  
  var result = AdsApp.search(query);
  
  while (result.hasNext()) {   
    var row = result.next();    
    var adGroupId = row.adGroup.id;
    adGroupIds.push(adGroupId);
  }
  
  log("Number of ad groups: "+adGroupIds.length);
  
  return adGroupIds;
}


function getObject(objArray, adGroupId) {
  for (var i=0;i<objArray.length;i++){
    if (objArray[i].adGroupId == adGroupId) {
      return objArray[i];
    }
  }
  error("Could not find object in array");
  return null;
}


function prepareOutputSheet() {
  
  var spreadsheet = SpreadsheetApp.openByUrl(SPREADSHEET_URL);
  if (!spreadsheet) {
    Logger.log("Cannot open new reporting spreadsheet") ;
    return ;
  }

  var sheet = spreadsheet.getActiveSheet();
  if (!sheet) {
    Logger.log("Cannot open new reporting sheet") ;
    return ;
  }  
  
  var numberOfRows=sheet.getLastRow() ;
  debug("NR of rows in output sheet: "+numberOfRows);
  
  if (numberOfRows == 0) { // the sheet has no header
    addHeaderToOutputSheet(sheet);
  }
  
  return sheet;
}


function addHeaderToOutputSheet(sheet) {
  
  var header = [
    "Date",
    "Campaign Name", 
    "Adgroup Name",
    "Gender",
    "Clicks",
    "Percentage of total AG Clicks",
    "CPC",
    "Diff from AG Avg CPC",
    "Cost",
    "Percentage of total AG Cost",
    "Conversions",
    "Percentage of total AG Conversions", 
    "Cost/Conv",
    "Diff from AG Avg Cost/Conv",
    "Suggested Adjustment"
    ];
  
  sheet.appendRow(header);
}


function addOutputToSheet(output, sheet) {
  
  // add data to the top of the sheet
  sheet.insertRowsBefore(2, output.length);
  var startRow = 2;  
  var range = sheet.getRange(startRow, 1, output.length, output[0].length);
  range.setValues(output) ;
  
  // sort
  //range.sort([{column: 1, ascending: false}, {column: 18, ascending: false}]);

  /*
  // format
  range = sheet.getRange(startRow, output[0].length, output.length);  
  var formats = [];
  for (var i=0; i<output.length; i++) {
    formats.push(["##.#%"]);
  }
  range.setNumberFormats(formats);
  */
}



/** HELPER FUNCTIONS **/

function log(string) {
 if (LOG == true) {
   Logger.log(string);
 }
}


function debug(string) {
  if (DEBUG == true) {
    Logger.log("# "+string);
  }
}

function error(string) {
  if (LOG == true) {
    Logger.log("### ERROR: "+string);
  }
}


// convert cost value to money data type
function toMoney(m) {
  return m * 1000000;
}

// convert money data type to cost value
function convertMoney(m) {
  return m / 1000000;
}

function isInArray(value, array) { 
  return array.indexOf(value) > -1;
}

function getTodaysDate() {
  return new Date();
}




function getStartDate(period) {
  
  var startDate;
  var today = new Date();
  
  switch(period) {
      
    case "THIS_YEAR" : 
      startDate = today.getFullYear()+"-01-01";
      break;
      
    case "LAST_30_DAYS" :
      startDate =  dateToISOString(new Date(Date.now() - 30*864e5)); // 864e5 == 86400000 == 24*60*60*1000 )
      break;

    case "LAST_60_DAYS" :
      startDate =  dateToISOString(new Date(Date.now() - 60*864e5)); // 864e5 == 86400000 == 24*60*60*1000 )
      break;

    case "LAST_90_DAYS" :
      startDate =  dateToISOString(new Date(Date.now() - 90*864e5)); // 864e5 == 86400000 == 24*60*60*1000 )
      break;
      
    default:
      startDate ="2020-01-01";
  }
  
  debug("startDate: "+startDate);
  
  return startDate;
}


function getEndDate(period) {

  var endDate;
  
  var yesterday = new Date(Date.now() - 864e5); // 864e5 == 86400000 == 24*60*60*1000 
 
  switch(period) {
      
    case "LAST_YEAR" : 
      endDate = "lastDayLastYear"; //TODO
      break;
      
    case "LAST_MONTH" :
      endDate = "lastDayLastMonth" //TODO
      break;
      
    default:
      endDate = dateToISOString(yesterday);
  }

  debug("endDate: "+endDate);
  
  return endDate;
}


function dateToISOString(date) {
   
  var year = date.getUTCFullYear();
  var month = date.getUTCMonth()+1;
  if (month < 10) {
    month = "0"+month;
  }
    
  var day = date.getUTCDate();
  if (day < 10) {
    day = "0"+day;
  }  
  var ISOString = year+"-"+month+"-"+day;
  
  return ISOString;
}


 

Join thousands of PPC geeks who already have access: