Most Airtable projects start as a spreadsheet that outgrew itself: forty columns, colour-coded rows, three people editing the same tab, and a VLOOKUP nobody dares touch. Moving it into Airtable is not a copy-paste job. A good migration turns columns into typed fields, tabs into related tables, and manual rituals into automations.
This tutorial walks the whole path on a realistic example and flags the decisions that cause rework if you get them wrong on day one.
The example
A Google Sheet called Client Work with three tabs:
Projects— client name, project name, status, owner, start date, due date, fee, notes.Time Log— date, person, project name (typed by hand), hours, billable?Contacts— name, email, company, phone.
That is three entities (projects, time entries, people/companies) held together by typed text. Airtable's job is to make those relationships real.
Step 1: audit before you import
Spend thirty minutes in the sheet, not in Airtable. For each column, write down:
- What it really holds. A
Statuscolumn with valuesActive,active,ACTIVE - urgent, andon hold?is one field with dirty data, not four statuses. - Whether it is entered or calculated. Calculated columns should become formula fields or rollups, never imported values.
- Whether it repeats. Client name appearing on 400 rows means clients are an entity that deserves its own table.
- Merged cells, header rows, and blank spacer rows. These break every importer. Flatten them now.
A quick clean-up pass in Sheets pays for itself: use Data > Data cleanup > Trim whitespace, then a pivot table or =UNIQUE() on each categorical column to see the real value list. Fix casing and typos in the sheet where find-and-replace is cheap.
Step 2: map columns to Airtable field types
This is the step that decides whether your base feels native or feels like a spreadsheet in a costume.
| Sheet column | Airtable field type | Why |
|---|---|---|
| Client name (repeats) | Link to another record → Clients | Enables rollups, filters, and one place to fix a name |
| Status (short value list) | Single select | Validation, colour, groupable, usable in automation conditions |
| Owner (a person on the team) | Collaborator | Powers "my records" views, Interface filters, and notifications |
| Tags (comma-separated) | Multiple select | Split on comma at import time |
| Fee | Currency | Formatting plus correct rollup maths |
| Start / Due date | Date | Set the field's timezone behaviour deliberately |
Billable? (TRUE/yes/1) | Checkbox | Normalise the source values first |
| Notes | Long text | Enable rich text only if you actually need it |
| Row-level total, days-late, margin | Formula | Recalculated forever, never stale |
| Sum of hours per project | Rollup | Lives on the parent, driven by children |
| Sheet row number, ad-hoc IDs | Autonumber / formula | Do not import a spreadsheet's row numbers as data |
Two type choices deserve extra thought:
Single select vs link to another record. If the value has attributes of its own (a client has an address, a contact, an owner), it is a record. If it is a flat label with under roughly twenty stable options, it is a select. Getting this wrong is the single most common cause of a base rebuild.
Collaborator vs text for people. Use collaborator for team members who log in; that unlocks personal views, record assignment, and "notify assignee" automations. Use a linked People table for contractors or contacts who do not have base access.
Step 3: design the tables before importing anything
For the example, the target schema is:
Clients— Name (primary), Domain, Owner, Status.Contacts— Name (primary), Email, Phone, Client (link → Clients).Projects— Name (primary), Client (link), Owner (collaborator), Status (select), Start, Due, Fee (currency), Hours logged (rollup), Notes.Time Entries— Entry ID (autonumber or formula primary), Date, Person, Project (link), Hours, Billable.
Create the tables and fields empty first. Importing into a table whose fields already exist and are already typed avoids Airtable guessing types for you, and guessing is where phone numbers become numbers and 03/04 becomes a date in the wrong locale.
Also decide the primary field for each table now. It should be human-readable and as unique as you can make it. For Time Entries, a concatenation such as 2026-03-04 — Priya — Website Rebuild beats a bare date.
Step 4: import the data in dependency order
Import parents first, children second: Clients, then Contacts, then Projects, then Time Entries. Airtable can create linked records from text on import, but only if the target record already exists — otherwise you get duplicates with slightly different spellings.
Per tab:
- In Sheets,
File > Download > Comma-separated valuesfor that tab only. - In Airtable, open the target table and use
Add or import > CSV file, choosing to append into the existing table. - In the mapping screen, match every column explicitly. Set any column you do not want to "Do not import" rather than letting it create a stray field.
- Import a 10-row test slice first. Check dates, decimals, links, and select options, then delete the test rows and run the full file.
For linked columns, make sure the source text matches the parent's primary field exactly. A quick =TRIM(PROPER(A2)) helper column in Sheets, or a lookup against your cleaned client list, removes most mismatches before they become records.
For multiple-select columns, Airtable splits on commas during import. If your source uses semicolons or slashes, convert them first.
When a script beats the importer
Use the Scripting extension when you need transformation the importer cannot do: splitting one sheet into two tables in a single pass, generating deterministic keys, or matching to existing records by email. A sketch:
const rows = await input.fileAsync('Upload CSV'); // or paste JSON
const clients = base.getTable('Clients');
const projects = base.getTable('Projects');
// build a lookup of existing clients by lowercased name
const existing = new Map();
for (const r of (await clients.selectRecordsAsync({fields: ['Name']})).records) {
existing.set((r.name || '').trim().toLowerCase(), r.id);
}
const toCreate = [];
for (const row of rows) {
const key = (row['Client name'] || '').trim().toLowerCase();
let clientId = existing.get(key);
if (!clientId) {
clientId = (await clients.createRecordsAsync([{fields: {Name: row['Client name'].trim()}}]))[0];
existing.set(key, clientId);
}
toCreate.push({fields: {
Name: row['Project name'],
Client: [{id: clientId}],
Status: {name: row['Status']},
Fee: Number(row['Fee']) || null,
}});
}
// createRecordsAsync accepts a maximum of 50 records per call
for (let i = 0; i < toCreate.length; i += 50) {
await projects.createRecordsAsync(toCreate.slice(i, i + 50));
}
The same 50-record batching rule applies to the REST API, so this pattern ports directly if you would rather run the migration from Node or Python.
Step 5: rebuild formulas, do not port them
Spreadsheet formulas reference cells; Airtable formulas reference fields on the current record. Common translations:
VLOOKUP/INDEX(MATCH())→ a link field plus a lookup field.SUMIF/COUNTIFacross a child sheet → a rollup on the parent (SUM(values),COUNTALL(values)).IF(ISBLANK(...))chains →IF({Field} = BLANK(), ...)orSWITCH()for cleaner branching.TODAY() - A2for ageing →DATETIME_DIFF(TODAY(), {Due}, 'days').CONCATENATE→&orCONCATENATE(), with\nhandling viaNotes & "\n" & More.- Conditional formatting rules → coloured views, or a formula field that emits an emoji flag such as
IF(DATETIME_DIFF(TODAY(), {Due}, 'days') > 0, "🔴", "🟢").
Rules that lived in a human's head ("we highlight it yellow when the client hasn't replied in a week") are the best candidates for automations: a scheduled trigger, a conditional check, and a Slack or email nudge.
Step 6: de-duplicate and reconcile
Before anyone trusts the base, prove the numbers.
- Row counts. Compare each table's record count against the source tab's row count minus headers and blanks. A grouped view by an imported
Source batchfield makes this easy — add that field on purpose and stamp each import. - Financial totals. Sum the
Feecolumn in Sheets and the same field in Airtable's summary bar. They should match to the cent. - Orphans. Create a filtered view where the link field is empty. Every orphan is a spelling mismatch you need to resolve.
- Duplicates. Add a formula key such as
LOWER(TRIM({Email})), group by it, and look for groups larger than one. The Deduplicate extension does this interactively; for a one-off clean-up it is usually faster than a script. - Spot-check 10 random records field by field against the sheet. Automated checks miss silent type coercions; eyes do not.
Step 7: sync or migrate? Pick one deliberately
If the spreadsheet must stay alive — a partner owns it, or a tool exports to it nightly — do not migrate it. Use Airtable Sync to pull the sheet in as a read-only synced table, then link your editable records to it. Synced tables cannot be edited in Airtable, which is a feature, not a limitation: it keeps one system of record.
Migrate outright when Airtable becomes the system of record. In that case the old sheet must die, or people will keep using it. Practical hygiene:
- Move the sheet to an archive folder and rename it
ARCHIVED 2026-03-04 — Client Work (read only). - Set it to view-only for everyone except the admin.
- Put a link to the new Airtable interface in cell A1 of every tab.
Step 8: build the interface, then cut over
Nobody adopts a base by looking at a grid. Before cutover, build one Interface page per role: a project list with filters for delivery, a "log my hours" form for the team, a client summary for the account lead. Grid views stay for admins; everyone else lives in interfaces.
A cutover that works:
- Freeze the sheet at a known time (end of a Friday, or the end of a billing period).
- Final delta import of any rows added since your test import.
- Reconcile counts and totals one more time.
- Train for twenty minutes on the interfaces, not the schema.
- Run parallel for one week in read-only mode on the sheet, so anyone who says "the old numbers were different" can be shown otherwise.
- Set permissions, then archive the sheet for good.
Mistakes we clean up most often
- One giant table. Everything in
Master, with 60 fields and aRecord typeselect. Split by entity; link them. - Text where links belong. Client names typed into every row means no rollups and endless typos.
- Importing calculated columns as values. They are stale the moment they land.
- Attachments left in Drive links. If files matter, move them into attachment fields during migration, or accept forever that half of them will break.
- Skipping the primary field decision. A base full of records named
Untitledor1,2,3is unusable in linked-record pickers. - No import batch stamp. Without it, rolling back a bad import means hand-picking rows.
Checklist
- Source columns audited; entered vs calculated separated
- Categorical values normalised in the sheet
- Target tables and typed fields created empty, parents first
- Primary fields chosen and human-readable
- 10-row test import verified per table
- Links resolve; zero orphans in the "link is empty" view
- Formulas rebuilt as formula, lookup, and rollup fields
- Counts and financial totals reconciled
- Duplicates resolved with a normalised key
- Interfaces built per role and permissions set
- Old sheet archived, renamed, and read-only
Migrations look small until you are three tabs deep in mismatched client names. If you would rather hand over the sheet and get back a base that reconciles to the cent, get in touch — data migration to Airtable is one of the things we do every week.