Airtable's no-code layer covers most of what a team needs. Then you hit the job it will not do: update 4,000 records from a lookup, call an API that has no native integration, or apply a rule that needs a loop. That is what scripting is for.
This tutorial covers the two places scripts run, the patterns that keep them fast and safe, and the mistakes that turn a working script into a 3am support call. It assumes you can write basic JavaScript — variables, for loops, async/await — but nothing Airtable-specific.
The two places a script can live
The Scripting extension runs on demand, inside a base, in a dashboard panel. You click Run, it executes, it can ask the user questions with input.textAsync() or input.recordAsync(), and it can print output with output.markdown(). Use it for one-off data surgery, migrations, clean-up jobs, and admin tools someone triggers by hand.
Automation "Run script" actions run headlessly as a step in an automation. No user input, no output.markdown() for humans — instead you receive input.config() from the automation and you return values with output.set() for later steps to consume. Use it for anything that must happen on a trigger or a schedule.
The API surface is nearly identical; the input/output layer is not. Code written for the extension will usually fail in an automation on the first input.textAsync(). Decide where a script lives before you write it.
Both run in a sandbox: no npm install, no filesystem, no secrets manager. Automation scripts have a hard runtime limit (about 30 seconds), which shapes almost every design decision below.
Your first script: reading records
const table = base.getTable('Projects');
const query = await table.selectRecordsAsync({
fields: ['Name', 'Status', 'Budget', 'Client']
});
for (const record of query.records) {
console.log(record.getCellValue('Name'), record.getCellValueAsString('Status'));
}
Three things to notice, because they are the difference between a script that scales and one that times out:
- Always pass
fields. Without it, Airtable fetches every field on every record, including long text and attachments you do not need. On a wide table this alone can be the difference between two seconds and twenty. getCellValuevsgetCellValueAsString. The first returns the raw shape — a number, a{id, name}object for a single select, an array of{id, name}for linked records. The second always returns a display string. Use the raw value for logic, the string for output.- Filter with a view, not with JavaScript.
selectRecordsAsync({fields: [...]})on a table reads the whole table.base.getTable('Projects').getView('Active').selectRecordsAsync(...)reads only what the view shows. Push the filtering down.
Writing records: batch in fifties
This is the single most important rule in Airtable scripting. Every write method — createRecordsAsync, updateRecordsAsync, deleteRecordsAsync — accepts at most 50 records per call. Loop one record at a time and a 2,000-record update becomes 2,000 round trips, which will not finish inside an automation's time limit.
The batching helper you will write once and paste forever:
async function batchUpdate(table, updates) {
for (let i = 0; i < updates.length; i += 50) {
await table.updateRecordsAsync(updates.slice(i, i + 50));
}
}
Build the array first, write once:
const table = base.getTable('Invoices');
const query = await table.selectRecordsAsync({fields: ['Amount', 'Tax', 'Total']});
const updates = [];
for (const record of query.records) {
const amount = record.getCellValue('Amount') || 0;
const tax = record.getCellValue('Tax') || 0;
const total = amount + tax;
if (record.getCellValue('Total') !== total) {
updates.push({id: record.id, fields: {'Total': total}});
}
}
await batchUpdate(table, updates);
console.log(`Updated ${updates.length} of ${query.records.length} records`);
The if matters as much as the batching: only write records that actually change. Rewriting an identical value still burns a write, still bumps the record's modified time, still fires any automation watching that field, and still shows up in revision history. Diff before you write.
Cell value shapes you will get wrong once
Writing to a field requires the shape that field expects:
| Field type | Value to write |
|---|---|
| Single line text, long text | 'some string' |
| Number, currency, percent | 42 (a number, not '42') |
| Checkbox | true / false |
| Single select | {name: 'Active'} or {id: 'sel...'} |
| Multiple select | [{name: 'A'}, {name: 'B'}] |
| Linked record | [{id: 'recXXXX'}] — always an array, always record IDs |
| Collaborator | {id: 'usrXXXX'} |
| Date | '2026-03-14' or an ISO string |
| Attachment | [{url: 'https://...'}] |
Two traps. First, writing {name: 'Blocked'} to a single select creates that option if it does not exist and you have permission — handy, or a slow-motion mess of near-duplicate options, depending on whether it was intentional. Second, linked record writes replace the whole cell. To add one link without dropping the existing ones, read the current array, append, and write the union:
const existing = record.getCellValue('Team') || [];
const merged = [...existing.map(r => ({id: r.id})), {id: newPersonId}];
await table.updateRecordAsync(record, {'Team': merged});
You cannot write to formula, rollup, lookup, autonumber, created-time, or "last modified" fields. Attempting it throws.
Automation scripts: input and output
In a Run script action you declare input variables in the UI (usually pulled from the trigger record) and read them like this:
const {recordId, status} = input.config();
const table = base.getTable('Orders');
const record = await table.selectRecordAsync(recordId, {fields: ['Customer', 'Total']});
if (!record) {
output.set('result', 'not-found');
return;
}
output.set('customerName', record.getCellValueAsString('Customer'));
output.set('result', 'ok');
Pass the record ID in, then fetch the record — do not pass twelve individual field values as separate config variables. IDs stay correct as the schema changes; a config variable pointing at a renamed field silently breaks.
output.set() values are available to later automation steps, which is how you feed a script's result into a "Send email" or "Update record" action.
Calling external APIs
fetch works, and this is where scripting earns its keep — any service with an HTTP API becomes reachable:
const response = await fetch('https://api.example.com/v1/enrich', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${input.config().apiKey}`
},
body: JSON.stringify({domain: 'example.com'})
});
if (!response.ok) {
throw new Error(`Enrichment failed: ${response.status} ${await response.text()}`);
}
const data = await response.json();
Three cautions:
- Secrets. There is no vault. An API key in a script is visible to everyone with editor access to the base and travels with any duplicate of it. For anything sensitive, put the key in a proxy you control (a small serverless function) and let the script call the proxy. At minimum, use a scoped, rotatable key and record where it lives.
- The time limit. Sequential
fetchcalls inside a loop over 200 records will blow the 30-second budget. Fan out withPromise.allin chunks, or restructure: have the automation process one record per run. - Check
response.ok.fetchdoes not throw on a 4xx or 5xx. An unchecked call happily writesundefinedinto forty records.
Errors, idempotency, and reruns
Scripts fail — the API is down, a field got renamed, someone deleted a view. Design for the rerun:
- Fail loudly. Throw on unexpected conditions. A thrown error marks the automation run as failed and shows in the run history; a silent
returnlooks like success forever. - Make it idempotent. Running the script twice on the same record should produce the same end state, not two duplicate child records. Before creating, check whether the thing already exists — a
Sync keyorExternal IDfield on the table makes this trivial. - Write a marker. For long clean-up jobs, tick a
Processedcheckbox as you go and filter it out at the start. If the run dies halfway, the next run resumes instead of redoing everything. - Log to a table. For scripts that matter, append a row to a
Script logtable with a timestamp, record count, and any error message. Automation run history is retained for a limited window and is awkward to search; your own log is not.
When not to write a script
Scripting is the most expensive thing in the base to maintain, because it is the only part a non-technical admin cannot read. Before writing one, check:
- Can a formula do it? Derived values almost always should be formulas — they recalculate for free and never need a rerun.
- Can a rollup do it? Counting or summing across linked records needs no code.
- Can native automation actions do it? Update record, create record, find records, conditional logic, and repeating groups now cover a lot of what used to require scripting.
- Is this really a sync problem? If you are scripting a nightly copy of data from another base, Airtable Sync is more reliable and free of maintenance.
Write the script when the logic genuinely needs a loop, a branch too gnarly for the automation UI, or an external call. Then comment it, because the person reading it in a year will not be you.
A short checklist before you ship
- Fields explicitly listed in every
selectRecordsAsync. - Reads scoped to a view where possible.
- Writes batched at 50 and diffed so unchanged records are skipped.
- No secrets pasted in plain text.
response.okchecked on everyfetch.- Errors thrown, not swallowed.
- A comment at the top saying what it does, who owns it, and what it assumes about the schema.
- Tested on a duplicate of the base before it touches production data.
If you want more on the surrounding plumbing, our Airtable API quickstart covers tokens and scopes for calling Airtable from outside, and the automation decision guide covers when to reach for Make or Zapier instead.
BaseBrainers writes, reviews, and inherits Airtable scripts for clients every week — including the ones somebody left behind. If you have a script nobody understands, or a job you suspect needs one, get in touch.