← Google Apps Script

Intermediate · I1

Formatting and cleaning data

Data that is correct but hard to read gets ignored. In this chapter scripts make a sheet clear in one click: styled headers, proper number formats, colours that flag problems, and a clean-up routine for messy pasted lists. Use the Class Marks sheet from B4.

Style a header row

Code.gs
function styleHeader() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Marks');
  const lastCol = sheet.getLastColumn();
  const header = sheet.getRange(1, 1, 1, lastCol);

  header
    .setBackground('#1a5fb4')
    .setFontColor('#ffffff')
    .setFontWeight('bold')
    .setHorizontalAlignment('center');

  sheet.setFrozenRows(1);           // header stays on screen when you scroll
  sheet.autoResizeColumns(1, lastCol);
}

Each set… method returns the range, so you can chain them: one statement, four formats. Put each call on its own line so it reads like a list.

MethodExample value
setBackground'#fff2cc', 'yellow', or null to clear
setFontColor'#cc0000'
setFontWeight'bold' or 'normal'
setFontStyle'italic' or 'normal'
setFontSize / setFontFamily12 / 'Sarabun'
setHorizontalAlignment'left', 'center', 'right'
setWraptrue to wrap long text
setBorder(true, true, true, true, null, null): top, left, bottom, right, inside lines

Every setter has a plural form (setBackgrounds, setFontColors…) that takes a 2D array, one value per cell.

Next: number formats →

Same value, different look

A number format changes how a value looks, never the value itself. Run this on an empty tab:

Code.gs
function formatNumbers() {
  const sheet = SpreadsheetApp.getActiveSheet();
  sheet.getRange('A1:D1').setValues([[1234.5, 0.825, new Date(), 45000]]);

  sheet.getRange('A1').setNumberFormat('#,##0.00');      // 1,234.50
  sheet.getRange('B1').setNumberFormat('0.0%');          // 82.5%
  sheet.getRange('C1').setNumberFormat('dd/mm/yyyy');    // 03/11/2026
  sheet.getRange('D1').setNumberFormat('"฿"#,##0');      // ฿45,000
}

What the cells show

A1  1,234.50
B1  82.5%
C1  03/11/2026
D1  ฿45,000
PatternMeans
0 / #A digit that always shows / only if needed
,Thousands separator
%Show as a percentage (multiplies by 100)
"text"Literal text, like a currency sign
dd mm yyyy hh:mmDay, month, year, hours, minutes
@Plain text: keeps leading zeros in phone numbers and IDs

Use getDisplayValue() to read the formatted text, and getValue() for the real number.

Next: colour by value →

Highlight rows by their mark

Green for 80 and over, red for under 50, no fill otherwise. Build the colours in memory, then write them in one call:

Code.gs
// Colour each row by its mark: one read, build colour arrays, one write.
function highlightMarks() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Marks');
  const n = sheet.getLastRow() - 1;
  const width = sheet.getLastColumn();
  const marks = sheet.getRange(2, 3, n, 1).getValues();

  const colours = marks.map(([m]) => {
    const colour = m >= 80 ? '#d9f2d9' : m < 50 ? '#fde0dc' : null;  // null = no fill
    return new Array(width).fill(colour);
  });
  sheet.getRange(2, 1, n, width).setBackgrounds(colours);
}
ABC
1NameRoomMark
2PloyM.5/372
3BenM.5/188
4MintM.5/345

Script or conditional formatting?

Sheets' own Format → Conditional formatting updates live as values change. A script colours the sheet only when it runs. Use the built-in feature for simple rules, and a script when the rule needs logic a rule can't express, or when you're cleaning a sheet once.

Next: sort and validate →

Sorting

Sort the body of the table, never the header. The column number counts from A = 1 across the whole sheet.

Code.gs
function sortExamples() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Marks');
  const body = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn());

  body.sort({ column: 3, ascending: false });   // highest mark first
  // Several keys: by room, then by name.
  // body.sort([{ column: 2, ascending: true }, { column: 1, ascending: true }]);
}

Shortcut: sheet.sort(3, false) sorts the whole sheet below the frozen rows by column C, descending.

Checkboxes and drop-downs

Code.gs
function addTickColumn() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Marks');
  const n = sheet.getLastRow() - 1;
  sheet.getRange(1, 5).setValue('Handed in?');
  sheet.getRange(2, 5, n, 1).insertCheckboxes();

  // A drop-down list in column F.
  const rule = SpreadsheetApp.newDataValidation()
    .requireValueInList(['Present', 'Late', 'Absent'], true)
    .setAllowInvalid(false)
    .build();
  sheet.getRange(1, 6).setValue('Today');
  sheet.getRange(2, 6, n, 1).setDataValidation(rule);
}

A ticked checkbox reads as true, unticked as false. The drop-down rule is built with a builder: start with newDataValidation(), add settings, finish with .build(). You will meet the same builder pattern again for triggers.

Next: cleaning data →

Clean a messy list

Make a tab called Contacts and paste in a list like this, with all its problems:

AB
1NameEmail
2  ploy  srisuk[email protected]
3BEN CARTER[email protected]
4
5Ploy Srisuk[email protected]
Code.gs
// Fixes the usual problems in a pasted list of names and emails:
// extra spaces, random capitals, blank rows and repeated people.
function cleanContacts() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Contacts');
  const [header, ...rows] = sheet.getDataRange().getValues();

  const seen = new Set();
  const cleaned = [];
  for (const [name, email] of rows) {
    const n = String(name).trim().replace(/\s+/g, ' ');
    const e = String(email).trim().toLowerCase();
    if (!n && !e) continue;            // skip blank rows
    if (seen.has(e)) continue;         // skip a repeated email
    seen.add(e);
    const proper = n.toLowerCase().replace(/\b\w/g, (ch) => ch.toUpperCase());
    cleaned.push([proper, e]);
  }

  sheet.clearContents();
  const out = [header, ...cleaned];
  sheet.getRange(1, 1, out.length, 2).setValues(out);
  console.log(`${rows.length} rows in, ${cleaned.length} rows out`);
}

Execution log

4 rows in, 2 rows out
AB
1NameEmail
2Ploy Srisuk[email protected]
3Ben Carter[email protected]
  • trim() removes spaces at the ends, and replace(/\s+/g, ' ') squeezes double spaces inside.
  • Emails are compared in lower case, so "Ploy@School" and "ploy@school" count as the same person.
  • A Set remembers which emails have been seen.

Test on a copy

A clean-up script rewrites the sheet. Duplicate the tab first (right-click → Duplicate) until you trust it. Apps Script also has quick built-ins for simple cases: range.trimWhitespace() and range.removeDuplicates().

Next: practice →

Practice

1. Zebra stripes Easy

Give the header a blue fill and colour every other row light grey, with one setBackgrounds call.

Show a solution
Code.gs
function zebraStripes() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const rows = sheet.getLastRow();
  const cols = sheet.getLastColumn();
  const colours = [];
  for (let r = 1; r <= rows; r++) {
    const c = r === 1 ? '#c6dafc' : r % 2 === 0 ? '#ffffff' : '#f1f3f4';
    colours.push(new Array(cols).fill(c));
  }
  sheet.getRange(1, 1, rows, cols).setBackgrounds(colours);
}

2. Flag duplicate emails Medium

Colour an email cell red if the same email (ignoring case) appears more than once in column B. Don't delete anything.

Show a solution
Code.gs
function markDuplicates() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const n = sheet.getLastRow() - 1;
  const emails = sheet.getRange(2, 2, n, 1).getValues().map(([e]) => String(e).toLowerCase().trim());
  const counts = {};
  emails.forEach((e) => (counts[e] = (counts[e] || 0) + 1));
  const colours = emails.map((e) => [counts[e] > 1 ? '#fde0dc' : null]);
  sheet.getRange(2, 2, n, 1).setBackgrounds(colours);
}

3. Thai phone numbers Challenge

Column B has phone numbers typed every possible way. Make them all 081-234-5678, and leave any that aren't 10 digits unchanged for a person to check.

Show a solution
Code.gs
// Turns "081 234 5678", "0812345678" and "+66812345678" into "081-234-5678".
function cleanPhones() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const n = sheet.getLastRow() - 1;
  const range = sheet.getRange(2, 2, n, 1);
  const out = range.getValues().map(([p]) => {
    let d = String(p).replace(/\D/g, '');        // digits only
    if (d.startsWith('66')) d = '0' + d.slice(2);
    if (d.length !== 10) return [p];             // leave odd ones for a human
    return [`${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`];
  });
  range.setNumberFormat('@').setValues(out);     // '@' = plain text, keeps the 0
}

Next: quiz →

Check yourself

1. Why can you write range.setBackground('#eee').setFontWeight('bold') on one line?
2. You want to colour 200 rows differently. Which is right?
3. What does setNumberFormat('0.0%') show for the value 0.825?
4. Phone numbers lose their first 0 when written. What fixes it?
5. Which JavaScript object is ideal for "have I seen this email before?"
6. setFrozenRows(1) does what?