Many internal tools need full CRUD against a sheet without exposing the spreadsheet UI.
listContacts / createContact / updateContact / deleteContact form a small API over columns A–E on Contacts.
IDs are UUIDs so deletes and updates do not depend on volatile row numbers from the client.
Pair with an HTML table UI that calls these via google.script.run and re-lists after each mutation.
Need this built? Hire a Google Apps Script developer →
Sheet / project setup
| Sheet | Column | Purpose |
|---|---|---|
| Contacts | A id | UUID primary key |
| Contacts | B name | Display name |
| Contacts | C email | |
| Contacts | D phone | Phone optional |
| Contacts | E updatedAt | Last write time |
What this script does
The four functions cover read and write paths; findRowById_ translates UUID to a 1-based row index.
Prerequisites
Contacts sheet with headers; web app or sidebar that calls the functions.
- Header row present
- Clients never send row numbers
- Authn/authz added for production
Walkthrough
createContact then listContacts; update fields; deleteContact; confirm the row is gone.
Edge cases
Concurrent edits can race — use LockService around update/delete in busy teams.
- deleteRow shifts indexes — always re-find by id
- Empty id rows are skipped in list
How to test
Create two contacts, update one phone, delete the other, list and assert length 1.
Hardening for production
Validate email format; soft-delete with a status column instead of deleteRow for audit.
Variations
Add pagination with start/limit; or move storage to Workspace database / JDBC.
Full code: Contacts CRUD
Call listContacts/createContact/updateContact/deleteContact from HtmlService. Keep UUIDs as the client identity.
/**
* Minimal CRUD API for a Contacts sheet, called from HtmlService UI.
* Columns: A id, B name, C email, D phone, E updatedAt
*/
const CONTACT_SHEET = "Contacts";
function listContacts() {
const sheet = sh_();
const values = sheet.getDataRange().getValues();
const out = [];
for (let i = 1; i < values.length; i++) {
if (!values[i][0]) continue;
out.push({
id: values[i][0],
name: values[i][1],
email: values[i][2],
phone: values[i][3],
updatedAt: values[i][4],
});
}
return out;
}
function createContact(data) {
const id = Utilities.getUuid();
sh_().appendRow([id, data.name, data.email, data.phone || "", new Date()]);
return { id: id };
}
function updateContact(id, data) {
const row = findRowById_(id);
if (!row) throw new Error("Not found");
const sheet = sh_();
sheet.getRange(row, 2, row, 5).setValues([[data.name, data.email, data.phone || "", new Date()]]);
return { ok: true };
}
function deleteContact(id) {
const row = findRowById_(id);
if (!row) throw new Error("Not found");
sh_().deleteRow(row);
return { ok: true };
}
function sh_() {
return SpreadsheetApp.getActive().getSheetByName(CONTACT_SHEET);
}
function findRowById_(id) {
const values = sh_().getDataRange().getValues();
for (let i = 1; i < values.length; i++) {
if (values[i][0] === id) return i + 1;
}
return null;
}- Line 7: Returns plain objects for JSON-friendly script.run.
- Line 25: Creates a stable primary key for each contact.
- Line 26: Inserts a new contact at the bottom.
- Line 31: Maps UUID to sheet row number.
- Line 34: Updates name/email/phone/updatedAt in place.
- Line 41: Removes the contact row after id lookup.
Deploy this example
- 01
Open Apps Script
In the bound spreadsheet: Extensions → Apps Script. For standalone projects, create one at script.google.com and link your Sheet by ID.
- 02
Paste and save
Add a .gs file, paste the code below, rename constants at the top (sheet names, column letters, API property keys), then save.
- 03
Authorize once
Run the main function from the editor. Accept OAuth scopes when prompted — triggers cannot run until authorization succeeds once.
- 04
Add the trigger
Triggers → Add trigger → choose the handler function and event (time-driven, on edit, or on form submit). Delete test triggers before production.
Before you run: sheet CRUD
- 1Contacts headers: id, name, email, phone, updatedAt
- 2UI uses returned id for update/delete
- 3Add domain or role checks for write methods
- 4Test concurrent updates if multiple clerks edit
- 5Backup the sheet before mass deletes
- 6Decide soft-delete vs hard delete
Frequently asked questions
Row numbers change after inserts/deletes. UUIDs stay stable.
Accept offset/limit parameters and slice the out array, or filter server-side.
Yes without locks. Use LockService.tryLock around updateContact.
Add listContactsByEmail or filter in listContacts before return.
Fine for small internal tools. Move to a real DB when you need transactions or large scale.
doGet returns HtmlService output; client JS calls these function names via google.script.run.
Accept an array and setValues a block after building rows — faster than many appendRow calls.