Unit testing in Google Apps Script: QUnitGS2 and a simple console runner

When your Apps Script project is just one tiny function attached to a Sheet, you can get away with “click Run and hope for the best”.

But as soon as your codebase grows, multiple files, complex business rules, integrations with Sheets, Docs, external APIs, that approach stops scaling. A small change in one function can silently break something completely different.

That’s where unit testing in Google Apps Script comes in.

In this article, we’ll cover:

  1. What unit testing is and why it matters for Apps Script
  2. The current landscape of unit testing solutions for GAS (QUnitGS2, GasT, Utgs, Jest + clasp, etc.)
  3. Classic unit testing principles adapted to Apps Script (AAA, dependency injection)
  4. How to use QUnitGS2 / QUnit2GS to build a proper test suite in Apps Script
  5. A lightweight console-based test runner that runs all test_ functions and logs PASS/FAIL in the execution log

By the end, you’ll have both a full-featured QUnit setup and a “zero-UI” runner you can use as a quick safety net in any project.

What is unit testing and why is it important?

Unit testing is the practice of testing small, isolated pieces of code (units), usually at the function level.

A typical unit test:

  • Calls a function with some inputs
  • Compares the result to an expected value
  • Fails loudly if they don’t match

In JavaScript (and therefore Apps Script), a unit test with QUnit looks like this:

QUnit.test('add() adds two numbers', (assert) => {
  const result = add(1, 2);
  assert.equal(result, 3, '1 + 2 should equal 3');
});

Why unit testing is especially useful in Google Apps Script projects:

  • Catch regressions early. When you refactor a Sheets automation or a Docs generator, tests tell you if something broke immediately instead of your users or clients discovering it later.
  • Refactor with confidence. You can rename functions, split files, change logic – and rely on tests as a safety net.
  • Executable documentation. A test like test_shouldApplyDiscountForTotalsOver1000 is more explicit than a vague comment buried in the code.
  • Faster debugging loops. Instead of manually re-running forms or simulating UI flows, you run the test suite and see exactly which scenario fails.

In “normal” JavaScript environments (Node/React), developers use tools like Jest, Mocha, QUnit, Ava… but Apps Script runs in a special environment that introduces a few constraints.

Limitations of Apps Script and existing unit testing solutions

Building a reliable unit testing structure in Google Apps Script (GAS) is essential for maintaining complex projects. But Apps Script is not a standard Node.js runtime:

  • No native local file system
  • No npm test directly inside the IDE
  • Limited setTimeout / async patterns
  • Heavy reliance on global services like SpreadsheetApp, DriveApp, MailApp, etc.

Because of that, unit testing solutions tend to fall into two big families:

  1. In-GAS frameworks – tests run on Google’s servers inside the Apps Script runtime
  2. Local testing – you pull code down with clasp and test it with Node tools, mocking Google services

In-GAS frameworks

These live directly in your Apps Script project.

ApproachFramework / ToolHow it worksProsCons
QUnit web UIQUnitGS2 / QUnit2GS
Adaptation of the popular QUnit JS library. Tests run in GAS, results shown via a doGet() web app.
Familiar QUnit API, nice HTML report, well documented, works well with Apps Script web apps.Requires deploying as a web app to see results; one more moving piece in your workflow.
TAP-style consoleGasT
TAP-compatible testing framework; tests log TAP output to the execution log.
Simple setup, console-based output, easy to integrate into basic scripts.Smaller community, less documentation than QUnit.
Minimal console libsUtgs / bmUnitTest
Lightweight libs focused on simple assertions and PASS/FAIL logs in Logger.log.
Very easy to drop into any script, no web deployment required.No GUI, you manage structure and conventions yourself.

Local testing with clasp + Jest/Ava

With the clasp CLI you can clone your Apps Script project locally, and then use any Node test framework you like (Jest, Ava, etc.) with mocks for Google services.

  • How it works
    • Use clasp to pull your code locally.
    • Write Jest/Ava tests.
    • Mock things like SpreadsheetApp and UrlFetchApp.
    • Run npm test locally.
  • Pros
    • Full Node ecosystem (TypeScript, Jest watch mode, coverage reports, CI/CD, VS Code integration).
  • Cons
    • Higher setup cost, especially for mocking Google services accurately.
    • Best suited to modular projects where business logic is already decoupled from Apps Script services.

Quick conclusion

If you want:

  • A visual, browser-based test runner → QUnitGS2 / QUnit2GS is an excellent choice
  • A blazing-fast, Node-like experience for pure logic → clasp + Jest is great.
  • A quick in-editor check that runs functions and logs results → a small custom console runner (or Utgs/bmUnitTest) is the most direct option.

In the rest of this article, we’ll combine both worlds:

  • QUnitGS2 for a “real” test suite with a UI
  • A tiny log-based test runner you can drop into any project

Classic unit testing principles in the Apps Script world

Before we jump into QUnit and custom runners, it’s worth aligning on some basic testing principles and how they apply to Apps Script.

AAA: Arrange, Act, Assert

A readable test follows the AAA pattern:

  1. Arrange – set up inputs, initial state, mocks
  2. Act – call the function under test
  3. Assert – verify the result (return value or side effect)

Example (pseudo-code):

function test_addTwoNumbers() {
  // Arrange
  const a = 2;
  const b = 3;
  const expected = 5;

  // Act
  const actual = add(a, b);

  // Assert
  assert(actual, expected, 'add() should return 5 for (2, 3)');
}

Labeling those three steps makes your Apps Script tests much easier to read and maintain.

Isolation and dependency injection in Apps Script

True unit tests should focus on one unit of logic at a time. That’s tricky in Apps Script because your code often calls global services directly:

function myFunction() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet(); // tightly coupled
  // ...
}

To make this testable, you can use dependency injection:

const fakeSpreadsheetService = {
  getActiveSpreadsheet() {
    return { /* fake sheet object */ };
  },
};

// In your test:
myFunction(fakeSpreadsheetService);

This pattern works great with both QUnitGS2 and a simple console runner.

Using QUnitGS2 (QUnit2GS) in Google Apps Script

Let’s start with a more “formal” test suite using QUnitGS2, which adapts the popular QUnit framework to Apps Script.

4.1. Install QUnitGS2 in your project

  1. Open your Apps Script project.
  2. Click add a library

3. Add the following script ID, and click Add:

1tXPhZmIyYiA_EMpTRJw0QpVGT5Pdb02PpOHCi9A9FFidblOc9CY_VLgG

A function to test

We’ll define a simple pure function to keep the focus on testing:

/**
 * Calculates the total amount after applying a percentage discount.
 *
 * @param {number} amount - Base amount before discount.
 * @param {number} discountPercent - Discount percentage between 0 and 100.
 * @return {number} The discounted amount, rounded to 2 decimals.
 * @throws {Error} If amount or discountPercent are invalid.
 */
function calculateDiscountedTotal(amount, discountPercent) {
  if (typeof amount !== 'number' || Number.isNaN(amount) || amount < 0) {
    throw new Error('amount must be a non-negative number');
  }

  if (
    typeof discountPercent !== 'number' ||
    Number.isNaN(discountPercent) ||
    discountPercent < 0 ||
    discountPercent > 100
  ) {
    throw new Error('discountPercent must be between 0 and 100');
  }

  const discount = (amount * discountPercent) / 100;
  const total = amount - discount;

  return Number(total.toFixed(2));
}

Wire up the QUnit web app

Add this in (for example) QUnitRunner.gs:

/* global QUnitGS2 */

/**
 * QUnit instance provided by QUnitGS2.
 * @type {QUnit}
 */
const QUnit = QUnitGS2.QUnit;

/**
 * Web app entry point that runs the QUnit test suite and returns HTML results.
 *
 * @param {Object} e - Web app request event.
 * @return {Object} HTML output with QUnit UI.
 */
function doGet(e) {
  QUnitGS2.init();

  registerTests();

  QUnit.start();
  return QUnitGS2.getHtml();
}

/**
 * Called from the client-side HTML to retrieve QUnit results
 * after the server-side test run completes.
 *
 * @return {Object} JSON results from the QUnitGS2 server.
 */
function getResultsFromServer() {
  return QUnitGS2.getResultsFromServer();
}

Define QUnit tests

Now create registerTests() and write your tests:

/**
 * Registers all QUnit test modules.
 */
function registerTests() {
  QUnit.module('calculateDiscountedTotal', () => {
    QUnit.test('returns original amount when discount is 0%', (assert) => {
      const result = calculateDiscountedTotal(100, 0);
      assert.equal(result, 100, '100 with 0% discount should stay 100');
    });

    QUnit.test('applies percentage discount correctly', (assert) => {
      const result = calculateDiscountedTotal(200, 10);
      assert.equal(result, 180, '10% discount on 200 should be 180');
    });

    QUnit.test('rounds to 2 decimals', (assert) => {
      const result = calculateDiscountedTotal(99.99, 5);
      assert.equal(result, 94.99, 'Should round result to 2 decimals');
    });

    QUnit.test('throws on negative amount', (assert) => {
      assert.throws(
        () => calculateDiscountedTotal(-10, 10),
        /amount must be a non-negative number/,
        'Negative amounts should throw'
      );
    });

    QUnit.test('throws when discount > 100%', (assert) => {
      assert.throws(
        () => calculateDiscountedTotal(100, 150),
        /discountPercent must be between 0 and 100/,
        'Discount > 100% should throw'
      );
    });
  });
}

Deploy as a web app and run tests

  1. Deploy → New deployment → Web app
  2. Execute as: your account
  3. Who has access: as you prefer (e.g. “Only me”)
  4. Copy the web app URL and open it in your browser

QUnitGS2 will:

  • Initialize QUnit
  • Run your registerTests()
  • Show a full QUnit HTML report with passing/failing tests

A simple console-based unit test runner (no web app required)

Sometimes you just want a quick pass/fail check in the Execution log without deploying a web app or integrating a full framework.

The script below implements a self-contained test runner that:

  • Looks for all global functions whose names start with test_
  • Runs them one by one
  • Logs PASS / FAIL and a short summary in the Apps Script execution log
// --- TEST UTILITIES: Place this in a file named 'TestRunner.gs' ---

/** @type {string} */
const TEST_PREFIX = 'test_';

/**
 * Checks if two values are strictly equal and logs a PASS/FAIL message.
 *
 * @param {unknown} actual - The value returned by the function under test (Act).
 * @param {unknown} expected - The predetermined correct value (Arrange).
 * @param {string} message - Description of the assertion.
 * @return {boolean} True if the assertion passes, false otherwise.
 */
function assert(actual, expected, message) {
  if (actual === expected) {
    Logger.log(`✅ PASS: ${message}`);
    return true;
  }

  Logger.log(`❌ FAIL: ${message} - Expected: ${expected}, Actual: ${actual}`);
  return false;
}

/**
 * Iterates through all global functions and runs those prefixed with 'test_'.
 * This acts as the central test runner.
 *
 * Run this manually from the Apps Script editor to execute the whole suite.
 */
function runAllTests() {
  Logger.log('====================================');
  Logger.log('🚀 STARTING UNIT TEST SUITE');
  Logger.log('====================================');

  let totalTests = 0;
  let passedTests = 0;
  let failedTests = 0;

  const globalScope = typeof globalThis !== 'undefined' ? globalThis : this;

  // Scan global functions and execute those starting with TEST_PREFIX.
  for (const key in globalScope) {
    if (Object.prototype.hasOwnProperty.call(globalScope, key)) {
      const candidate = globalScope[key];
      if (typeof candidate === 'function' && key.startsWith(TEST_PREFIX)) {
        const testName = key;
        Logger.log(`\n--- Running: ${testName} ---`);

        try {
          candidate();
          passedTests++;
        } catch (e) {
          failedTests++;
          const message = e instanceof Error ? e.message : String(e);
          Logger.log(`💥 ERROR in ${testName}: ${message}`);
        }

        totalTests++;
      }
    }
  }

  Logger.log('\n====================================');
  Logger.log(`RESULTS: ${totalTests} tests run`);
  Logger.log(`PASSED: ${passedTests}`);
  Logger.log(`FAILED: ${failedTests}`);
  Logger.log('====================================');
}
  1. Paste TestRunner.gs into your Apps Script project.
  2. Add tests by creating functions whose names start with test_ (in the same project).
  3. In the editor, select runAllTests and click Run ▶️.
  4. Open View → Logs to see a step-by-step PASS/FAIL log and the final summary.

This gives you:

  • A very fast feedback loop while coding
  • No web app deployment required
  • A simple way to run a quick suite every time you touch the script

If you like this pattern, you can evolve it:

  • Add more assertion helpers (assertNotEquals, assertTrue, assertDeepEqual, …)
  • Add timing information per test
  • Exit early if a critical test fails (for long suites)

Putting it together

There’s no single “right” way to do unit testing in Google Apps Script, but a good strategy looks like this:

  • Use pure functions + dependency injection for your core business logic
  • Cover that logic with tests – either with QUnitGS2 (web UI) or your own console runner
  • Keep Apps Script service calls (SpreadsheetApp, DriveApp, etc.) thin and easy to mock
  • For bigger projects or front-end code, consider clasp + Jest and a local toolchain

Start small: pick one critical module (invoice calculation, validation rules, date logic…), write a handful of tests, and get used to running them every time you make a change.

Once you’ve seen a refactor go through while your QUnitGS2 panel and/or console runner stays fully green, you’ll never want to go back to “click Run and cross your fingers” again.

Solving Slow Google Sheets: Boost Performance with Smart Tricks and Automation

Google Sheets is amazing, until it isn’t. As your spreadsheets grow with formulas, collaborators, and live data, they can get painfully slow. Here are all the tricks I’ve found to help my clients get better performances.

Why Google Sheets Get Slow

  • Too many formulas, especially volatile ones (NOW(), TODAY(), RAND())
  • Large datasets (thousands of rows/columns)
  • Repeated or redundant calculations
  • Overuse of heavy formulas or IMPORTRANGE, ARRAYFORMULA, QUERY…
  • Dozens of collaborators editing simultaneously
  • Conditional formatting or custom functions on big ranges

Quick Fixes to Speed Things Up

✔️ Tidy Up Your Formulas

Start by removing or replacing volatile functions like NOW() or RAND() with static values when you don’t need them to update constantly. Use helper columns to calculate something once and reference that instead of repeating logic across rows.

Use fixed ranges (A2:A1000) instead of full-column references. Every formula referencing A:A makes Google Sheets look at all 50,000+ possible rows, even if you’re only using 1,000.

✔️ Lighten the Load

Think of your sheet as a backpack: if it’s stuffed with years of unused data and unnecessary features, it’ll be slow to move. Delete unused rows and columns. Archive old data to separate files. Keep the main spreadsheet focused on what matters now.

✔️ Be Visual, Not Flashy

Conditional formatting, while beautiful, is a silent performance killer. Use it selectively and avoid applying rules to entire columns or rows. Likewise, go easy on charts and embedded images. Move your dashboards to a separate file if needed.

Apps Script and Heavy-Duty Sheets

When dealing with large spreadsheets, your scripting strategy matters. Google Apps Script offers two main interfaces: SpreadsheetApp and the advanced Sheets API. While SpreadsheetApp is easier to use, it becomes painfully slow with big datasets because it handles operations cell by cell.

Instead, for serious performance, we lean heavily on the Sheets API and its batchUpdate method. This allows us to push changes to many cells at once, minimizing round trips and drastically speeding up execution.

Here’s a simple example that injects data into a large range using the Sheets API, not SpreadsheetApp:

// Example of data
const data = [{
  "range": "A35",
  "values": [["TEST"]]
}];

/**
 * Write to multiple, disjoint data ranges.
 * @param {string} spreadsheetId The spreadsheet ID to write to.
 * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchUpdate
 */
function writeToMultipleRanges(spreadsheetId, data, sheetName) {

  let ssGetUrl = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`
  let options = {
    muteHttpExceptions: false,
    contentType: 'application/json',
    method: 'get',
    headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() }
  };
  let ssGetresponse = JSON.parse(UrlFetchApp.fetch(ssGetUrl, options));
  let sheets = ssGetresponse.sheets;
  let rowCount = 0;
  let sheetId = 0;
  sheets.forEach(sheet => {
    if (sheet.properties.title == sheetName) {
      rowCount = sheet.properties.gridProperties.rowCount
      sheetId = sheet.properties.sheetId;
    }
  });

  let num = parseInt(String(data[0]["range"]).split("!")[1].replace(/[^0-9]/g, ''));
  if (rowCount < num) {
    let diff = num - rowCount;
    let resource = {
      "requests": [
        {
          "appendDimension": {
            "length": diff,
            "dimension": "ROWS",
            "sheetId": sheetId
          }
        }
      ]
    };
    let ssBatchUpdateUrl = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}:batchUpdate`
    let options = {
      muteHttpExceptions: true,
      contentType: 'application/json',
      method: 'post',
      payload: JSON.stringify(resource),
      headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() }
    };
    let response = JSON.parse(UrlFetchApp.fetch(ssBatchUpdateUrl, options));
  }

  const request = {
    'valueInputOption': 'USER_ENTERED',
    'data': data
  };

  try {
    const response = Sheets.Spreadsheets.Values.batchUpdate(request, spreadsheetId);
    if (response) {
      return;
    }
    console.log('response null');
  } catch (e) {
    console.log('Failed with error %s', e.message);
  }
}

/**
 * Converts a row and column index to A1 notation (e.g., 3, 5 → "E3").
 *
 * @param {number} rowIndex - The 1-based row index.
 * @param {number} colIndex - The 1-based column index.
 * @returns {string} The A1 notation corresponding to the given indices.
 */
function getA1Notation(rowIndex, colIndex) {
  if (rowIndex < 1 || colIndex < 1) {
    throw new Error("Both rowIndex and colIndex must be greater than or equal to 1.");
  }
  let columnLabel = "";
  let col = colIndex;
  while (col > 0) {
    const remainder = (col - 1) % 26;
    columnLabel = String.fromCharCode(65 + remainder) + columnLabel;
    col = Math.floor((col - 1) / 26);
  }
  return columnLabel + rowIndex;
}

Freeze Formulas

Often, you only need a formula to calculate once, not every time the sheet updates.

Instead of leaving it active and recalculating endlessly, you can use Apps Script to compute the values once and then lock them in as static data.

The script below is designed for that: it keeps the original formulas in the second row (as a reference and for future recalculations), then copies those formulas down the column, computes all results, and finally replaces everything below the second row with raw values. The result? A faster sheet that still preserves its logic blueprint in row two.

/**
 * 
 */
function Main_Update_Formula_Data(ssId, sheet) {

  // Get formulas in first row
  const formulas = sheet.getRange(2, 1, 1, sheet.getLastColumn()).getFormulas()[0];

  // Get last row index
  const lastRowIndex = sheet.getLastRow();

  // Prepare ranges to treat
  const ranges = [];

  // Loop through formulas to expand until the end
  for (let i = 0; i < formulas.length; i++) {

    if (formulas[i] != ""
      && !String(formulas[i]).startsWith("=arrayformula")
      && !String(formulas[i]).startsWith("=ARRAYFORMULA")
      && !String(formulas[i]).startsWith("=ArrayFormula")) {

      const thisRange = {
        startRowIndex: 1,
        endRowIndex: lastRowIndex - 1,
        startColumnIndex: i,
        endColumnIndex: i + 1,
        formulas: [formulas[i]],
      };

      ranges.push(thisRange);
    }
  }

  // Inject and freeze data except first row of formulas
  injectAndFreezeFormulas(ssId, sheet.getSheetId(), sheet.getName(), ranges);
}


/**
 * Injects formulas into multiple ranges, waits for computation, then replaces them with static values.
 * @param {string} spreadsheetId - Spreadsheet ID.
 * @param {number} sheetId - Numeric sheet ID.
 * @param {string} sheetName - Sheet name (for value reads/writes).
 * @param {Array<Object>} ranges - Array of range configs like:
 * @return {void}
 */
function injectAndFreezeFormulas(spreadsheetId, sheetId, sheetName, ranges) {
  // --- Inject formulas via batchUpdate
  const requests = [];
  ranges.forEach(rangeDef => {
    const {
      startRowIndex,
      endRowIndex,
      startColumnIndex,
      endColumnIndex,
      formulas,
    } = rangeDef;

    formulas.forEach((formula, idx) => {
      requests.push({
        repeatCell: {
          range: {
            sheetId,
            startRowIndex,
            endRowIndex,
            startColumnIndex: startColumnIndex + idx,
            endColumnIndex: startColumnIndex + idx + 1,
          },
          cell: {
            userEnteredValue: { formulaValue: formula },
          },
          fields: 'userEnteredValue',
        },
      });
    });
  });

  Sheets.Spreadsheets.batchUpdate({ requests }, spreadsheetId);
  Logger.log(`Formulas injected into ${ranges.length} range(s)`);

  // --- Wait briefly to ensure calculations are done
  Utilities.sleep(1000);

  // --- Read computed values via API (valueRenderOption: UNFORMATTED_VALUE)
  const readRanges = ranges.map(r =>
    `${sheetName}!${columnLetter(r.startColumnIndex + 1)}3:` +
    `${columnLetter(r.endColumnIndex)}${r.endRowIndex + 1}`
  );

  const readResponse = Sheets.Spreadsheets.Values.batchGet(spreadsheetId, {
    ranges: readRanges,
    valueRenderOption: 'UNFORMATTED_VALUE',
  });

  // --- Reinject values (overwrite formulas)
  const writeData = readResponse.valueRanges.map((vr, idx) => ({
    range: readRanges[idx],
    values: vr.values || [],
  }));

  Sheets.Spreadsheets.Values.batchUpdate(
    {
      valueInputOption: 'RAW',
      data: writeData,
    },
    spreadsheetId
  );

  Logger.log(`Formulas replaced by static values in ${writeData.length} range(s)`);
}

/**
 * Helper — Convert a column index (1-based) to A1 notation.
 *
 * @param {number} colIndex - 1-based column index.
 * @return {string}
 */
function columnLetter(colIndex) {
  let letter = '';
  while (colIndex > 0) {
    const remainder = (colIndex - 1) % 26;
    letter = String.fromCharCode(65 + remainder) + letter;
    colIndex = Math.floor((colIndex - 1) / 26);
  }
  return letter;
}

Did you know all of these tricks? If you’ve got a favorite performance tip, clever automation, or formula hack, drop it in the comments! Let’s keep our Sheets smart, fast, and fun to work with.

How to Find a Google Forms Question ID

Working with Google Apps Script and Google Forms often means mapping answers to the right question. For that, you need the question ID, a stable identifier that won’t change if you edit the title.

In this post, we’ll cover two simple ways to grab it: with Apps Script (getItems()) and via DevTools inspection of the live form. These methods are perfect for automation, building prefilled URLs, and keeping your form integrations reliable.

With Apps Script (getItems())

Use FormApp to loop through all items and match on the title. Great when you already know the exact question label.

/**
 * Return the question ID (string) that matches a given title.
 * @param {FormApp.Form} form
 * @param {string} questionTitle
 * @return {string|null}
 */
function retrieveItemId(form, questionTitle) {
  const items = form.getItems();
  for (let i of items) {
    if (i.getTitle().trim() == questionTitle.trim()) {
      return i.getId().toString();
    }
  }
  return null;
}

Why this is useful: you can rename a question in the UI without breaking scripts that rely on the ID later (IDs are stable; titles are not).

DevTools inspection (quick & dirty)

If you only need a question ID once and you have the live Google Form open, you can grab it straight from the page markup.

Steps:

  1. Open the live form (the “view” URL, not the editor).
  2. Right-click → Inspect (Chrome DevTools).
  3. Press Ctrl/Cmd + F and search for the exact question title (e.g., Name).
  4. Look for a parent <div> with attributes like jsmodel and data-params. Inside that blob you’ll typically see both IDs:
    • The first number after the title block is the Apps Script itemId (what FormApp.getItems() returns).
    • Inside a nested array you’ll also see the prefill entry id used in URLs.
<div jsmodel="CP1oW"
     data-params="... [826355120, &quot;Name&quot;, ..., [[2092238618, null, true, ...]] ] ...">
  ...
</div>

826355120itemId (returned by item.getId() in Apps Script)

2092238618prefill entry id (used as entry.2092238618=... in a prefilled link)

When to use this approach: fast, one-off lookup while you’re building your Apps Script, testing a prefilled URL, or debugging.
Caveat: this relies on the page’s internal structure, which can change. For anything automated or repeatable, prefer Method 1 (FormApp) or Method 3 (Forms API).

How to Create Dependent Drop-Down Lists in Google Sheets (Dynamic Cascading Drop-Downs)

Google Sheets drop-downs are a great way to control inputs and keep your data clean. A classic use case is to have different options for a drop down, depending on what was selecting in another.

That’s where dependent drop-down lists (also called cascading drop-downs) come in. For example:

  • In Drop-Down 1, you select a Region (Europe, Asia, America).
  • In Drop-Down 2, you only see the Countries from that region (Europe → France, Germany, Spain).

This guide shows you step by step how to build dependent drop-downs in Google Sheets — without named ranges, using formulas and a simple script for better usability.

Step 1 – Organize your source data

On a sheet named Data, enter your list in two columns:

Important: Use one row per country (don’t put “France, Germany, Spain” in a single cell).

Step 2 – Create the drop-down lists

The key is not to link drop-downs directly to your raw lists, but to use formulas that generate the lists dynamically. These formulas can live on the same sheet as your data or on a separate one. In this example, I’ll place them in Sheet2 to keep the Data sheet uncluttered.

In A2, I will add the formula:

=SORT(UNIQUE(FILTER(Data!A2:A, Data!A2:A<>"")))

This way, the first drop-down will display a clean list of countries without any duplicates.

And in B2, assuming the country drop-down will be in C1 :

=IF(C1<>"","",SORT(UNIQUE(FILTER(Data!B2:B, Data!A2:A=C1))))

Step 3 – Create the drop-down

Now we can simply add two drop-downs in C1 and D1, using the “Dropdown from a range” option in the Data validation menu.

The second drop-down will now display only the values that match the selection from the first drop-down. You can then hide columns A and B to keep your sheet tidy — and voilà!

Stop Working with 2D Arrays in Google Sheets™ / Apps Script

If you’ve ever pulled data from a Google Sheet in Apps Script, you know the pain:
getValues() always gives you a 2D array.

[
  ["Name", "Email", "Role"],
  ["Alice", "alice@example.com", "Admin"],
  ["Bob", "bob@example.com", "Editor"],
  ["Charlie", "charlie@example.com", "Viewer"]
]

That format is fine for small scripts, but it quickly becomes messy.
You end up with unreadable code like:

const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
  const name = data[i][0];
  const email = data[i][1];
  const role = data[i][2];
  // ...
}

Hard to read. Easy to break. And if someone reorders the spreadsheet columns, your code will suddenly pull the wrong data.


A Cleaner Way: Convert to Array of Objects

Instead of juggling indexes, let’s wrap the data in a class that automatically maps each row into an object , using the first row of the sheet as headers.

class Data {
  constructor(sheetName) {
    this.spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    this.sheetName = sheetName;
    this.sheet = this.spreadsheet.getSheetByName(this.sheetName);

    if (!this.sheet) throw `Sheet ${this.sheetName} not found.`;

    const data = this.sheet.getDataRange().getValues();
    this.headers = data.shift();
    this.rows = data.map(row =>
      row.reduce((record, value, i) => {
        record[this.headers[i]] = value;
        return record;
      }, {})
    );
  }
}

Now, when we create a new Data instance:

function demo() {
  const users = new Data("Users");
  Logger.log(users.rows);
}

We can now have an array of objects as follow:

[
  { Name: "Alice",   Email: "alice@example.com",   Role: "Admin" },
  { Name: "Bob",     Email: "bob@example.com",     Role: "Editor" },
  { Name: "Charlie", Email: "charlie@example.com", Role: "Viewer" }
]

From here, working with the data is straightforward — no more i and j index gymnastics or confusing double loops!

Fix Chart Color Shuffle in Google Sheets using the Sheets API

Have you ever noticed that chart colors in Google Sheets sometimes shuffle around when your data changes? This usually happens when you build a chart on top of a pivot table. If one of the series (like a product or category) isn’t present in the filtered data, Google Sheets automatically re-assigns colors.

The result: one week “Surf Wax” is blue, the next week it’s orange, and your report suddenly looks inconsistent. Not great if you’re sending charts to clients or managers who expect a clean, consistent palette.

In this tutorial, we’ll fix the problem once and for all by locking chart colors using the Google Sheets API from Apps Script.

Why the colors shuffle

Charts in Google Sheets don’t store colors per label by default, they store them per series position. So if your chart shows [Surf Wax, Leash, Wetsuit] this week, those get assigned colors 0, 1, 2. But if next week [Pad] is missing, everything shifts left and the colors move with them.

That’s why you see colors “jump” around when some categories are empty.

The fix: define your own color map

The solution is to create a color map table in your sheet that tells Apps Script what color each item should always use.

Example in range P2:Q:

The trick is that we don’t actually use the text in the “Color” column, we use the cell background color. This makes it easy to set and change colors visually.

Using the Sheets API to lock colors

The built-in SpreadsheetApp class in Apps Script doesn’t let us fully control chart series. For that, we need the Advanced Sheets Service (a wrapper around the Google Sheets REST API).

To enable it:

  • In the Apps Script editor, go to Services > Add a service > turn on Google Sheets API v4.

The Apps Script solution

Here’s the script that applies your custom colors to the first chart on the Sales sheet:

/**
 * Apply chart colors from mapping in P2:Q to the first chart in "Sales".
 */
function updateChartColors() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("Sales");
  const spreadsheetId = ss.getId();

  // 1. Build color map (Item -> hex from background)
  const mapRange = sheet.getRange("P2:Q");
  const values = mapRange.getValues();
  const bgs = mapRange.getBackgrounds();

  const colorMap = {};
  values.forEach((row, i) => {
    const item = row[0];
    const color = bgs[i][1];
    if (item && color && color !== "#ffffff") {
      colorMap[item] = color;
    }
  });

  // 2. Get chart spec from Sheets API
  const sheetResource = Sheets.Spreadsheets.get(spreadsheetId, {
    ranges: [sheet.getName()],
    fields: "sheets(charts(chartId,spec))"
  }).sheets[0];

  if (!sheetResource.charts) return;
  const chart = sheetResource.charts[0];
  const chartId = chart.chartId;
  const spec = chart.spec;

  // 3. Apply colors by matching series headers with map
  if (spec.basicChart && spec.basicChart.series) {
    spec.basicChart.series.forEach((serie, i) => {
      const src = serie.series?.sourceRange?.sources?.[0];
      if (!src) return;

      // Get header label (like "Surf Wax", "Leash", etc.)
      const header = sheet.getRange(
        src.startRowIndex + 1,
        src.startColumnIndex + 1
      ).getValue();

      if (colorMap[header]) {
        spec.basicChart.series[i].colorStyle = {
          rgbColor: hexToRgbObj(colorMap[header])
        };
      }
    });
  }

  // 4. Push update back into the chart
  Sheets.Spreadsheets.batchUpdate(
    {
      requests: [
        {
          updateChartSpec: {
            chartId,
            spec
          }
        }
      ]
    },
    spreadsheetId
  );
}

/**
 * Convert #RRGGBB hex to Sheets API {red, green, blue}.
 */
function hexToRgbObj(hex) {
  const bigint = parseInt(hex.slice(1), 16);
  return {
    red: ((bigint >> 16) & 255) / 255,
    green: ((bigint >> 8) & 255) / 255,
    blue: (bigint & 255) / 255
  };
}

How it works

  1. Read the color map from P2:Q using the cell background color.
  2. Fetch the chart spec from the Sheets API.
  3. For each series, look up the header label from the pivot table (like “Wax Remover”).
  4. If it exists in the map, apply the mapped color using colorStyle: { rgbColor: … }.
  5. Push the modified spec back to the chart with updateChartSpec.

The result: your chart always uses the same colors, no matter which categories are present.

Wrapping up

With this approach, you’ll never have to worry about Google Sheets “helpfully” reshuffling your chart colors again. Once you set your palette in the sheet, a single click on a button can re-apply it to your chart. And because it uses the Sheets API directly, the colors are actually saved in the chart definition itself.

That means your reports stay consistent, your managers stop asking “why is Surf Wax orange this week?”, and your charts look clean and professional every time.

The Multi-Account Trap in Google Workspace Add-ons (And How to Avoid It)

If you’re building Google Workspace Add-ons or Editor Add-ons, there’s a good chance you’ve encountered mysterious PERMISSION_DENIED errors, especially when using google.script.run in custom sidebars.

The kicker? The same code works flawlessly for some users, and fails catastrophically for others — without any obvious reason.

Welcome to the multi-account execution context problem.

In this article, I’ll break down:

  • When and why this error occurs
  • How to reproduce it
  • What you can do to avoid it (or at least handle it gracefully)
  • My own learnings building real-world Add-ons

Add Invisible IDs to Google Docs Paragraphs with Apps Script

Ever wanted to track paragraphs in a Google Doc without adding visible clutter? Whether you’re syncing content with an external system, managing edits across collaborators, or linking comments to specific sections, this technique lets you embed invisible unique IDs inside your document using zero-width characters.

Let’s dive into how this works, and how you can use it in your own Docs.

How to Push Big Datasets into Google Sheets with Apps Script (Without Crashing!)

Copy-pasting big datasets manually into Google Sheets can quickly turn into a nightmare: slowdowns, crashes, weird formatting issues… you name it.

But guess what? With a little bit of Google Apps Script magic, you can automate the entire process, safely and efficiently.

In this guide, I’ll show you how to push large amounts of data into your sheet like a pro, without risking a browser meltdown.

(And don’t worry, I’ll keep it simple and copy-paste ready.) 

Automatically Email Google Form Submissions (Including File Uploads)

Ever wished you could get an instant email when someone submits your Google Form complete with their answers and any files they uploaded? With a few lines of Apps Script, you can!

Let’s break it down

Why Use This Script?

This solution answers a common need: getting notified via email when someone submits a form response. Especially useful when:

  • You’re collecting job applications or portfolios.
  • You want to automate intake notifications.
  • Your form includes file uploads, and you want the file attached in the email.
  1. Access Apps Script from your Google Forms
  2. Insert the snippet
  3. OnFormSubmit(e) trigger

Log Function Performance in Google Apps Script

In Google Apps Script, execution time matters. With quotas on simultaneous runs and total runtime, long or inconsistent function execution can cause failures or delays. This is especially important when using services like LockService, where you need to define realistic wait times.

This article shows how to track execution time and errors in your functions—helping you stay within limits and optimize performance as your scripts grow.

  1. Build a Simple Performance Tracker
  2. Choose Where to Store Your Metrics
  3. Apply Tracking to Your Existing Functions