Almost every business we work with started out in a spreadsheet or an Airtable base, and that was the right call. They’re quick, flexible and everyone already knows how to use them. Then one day the tool starts running the business instead of the other way round.
We’ve helped several teams past that point: Scalers moved from Airtable to a Retool app on PostgreSQL, a hospitality business moved from Airtable to Supabase with around 1,300 attachments, and Instacoach kept its Google Sheet with PostgreSQL behind it. This article covers when to move, and then walks through the migration script we’d start from, run against a realistic export of an events agency’s base: 142 clients, 620 projects, tags, linked records and attachments.
Signs you’ve outgrown it
- You’re hitting record, automation or API limits.
- Views take long enough to load that people complain.
- Only one person understands the formulas, and everyone gets nervous when they’re on holiday.
- Clients, contractors or departments need to see only their own records, and the tool can’t do it.
- The same data lives in three places and they disagree.
- Connecting it to your other tools has turned into a pile of fragile automations nobody wants to touch.
One of these is a nuisance. Three or more usually means it’s time.
| Stay on Airtable or Sheets | Move to a database |
|---|---|
| A small team edits the data by hand | Several teams, clients or systems read and write it |
| Everyone may see everything | People need to see only their own records |
| Thousands of rows | Hundreds of thousands, growing every month |
| Reports are simple filters and sums | Reports join data from several tables or apps |
What to move to
For most businesses the answer is a relational database: PostgreSQL, often through Supabase, which adds sign-in, file storage and an API on top. It copes with millions of rows, keeps the relationships between records honest, and gives each person exactly the access they should have.
The database is only half of it. People still need screens to work in, usually a Retool app for the internal team or a customer portal for clients. Build the database without the screens and the team drifts back to the spreadsheet within a month.

Step 1: Get a clean export
Airtable exports each view as CSV. Before you export, add two fields to every table: a formula field called Record ID with the formula RECORD_ID(), and a Last modified time field. The CSV doesn’t include Airtable’s internal record ids otherwise, and without them you can’t match rows on a second run.
Look at what the export actually contains. Linked records come out as the linked records’ names, comma-separated. Multi-select fields come out the same way. Currency is text with a dollar sign and commas, and attachments are written as “name (url)”. None of that should go into the database as-is.
Step 2: Design tables, not a copy of the grid

create table clients (
id bigint generated always as identity primary key,
airtable_id text unique not null, -- from a RECORD_ID() formula field
name text not null,
email text,
phone text
);
create unique index clients_name_key on clients (lower(trim(name)));
create type project_status as enum ('Lead', 'Proposal sent', 'Booked', 'Delivered', 'Invoiced', 'Paid');
create table projects (
id bigint generated always as identity primary key,
airtable_id text unique not null,
name text not null,
status project_status not null,
budget numeric(12, 2), -- null when the base had no value
event_date date,
modified_at timestamptz not null
);
create table project_clients ( -- a linked-record field can hold several clients
project_id bigint references projects on delete cascade,
client_id bigint references clients,
primary key (project_id, client_id)
);
create table tags (id bigint generated always as identity primary key, name text unique not null);
create table project_tags (
project_id bigint references projects on delete cascade,
tag_id bigint references tags,
primary key (project_id, tag_id)
);
create table attachments (
id bigint generated always as identity primary key,
project_id bigint not null references projects on delete cascade,
filename text not null,
source_url text not null, -- Airtable URL; copied to storage in a separate step
storage_key text,
unique (project_id, filename)
);Two details matter more than they look. The unique index on lower(trim(name)) stops “Summit Hotels” and “summit hotels ” becoming two clients again. And status is an enum, so a typo can’t create a seventh status that no report knows about.
Step 3: Validate before you load anything
The script reads the CSVs, cleans each value (phone numbers, money, both date formats, comma-separated links and tags), then checks the export for the problems real bases have. Duplicates and missing budgets are reported; links to records that don’t exist block the load, because guessing would put projects under the wrong client.
def validate(clients, projects):
problems, blocking = [], False
seen = {}
for c in clients:
key = norm(c['Name'])
if key in seen:
problems.append(f"duplicate client: '{c['Name']}' ({c['Record ID']}) matches '{seen[key]['Name']}' ({seen[key]['Record ID']})")
else:
seen[key] = c
for p in projects:
for name in filter(None, p['Client'].split(',')):
if norm(name) not in seen:
problems.append(f"{p['Record ID']} '{p['Project']}': linked client '{name}' not in Clients")
blocking = True
if not p['Budget'].strip():
problems.append(f"{p['Record ID']} '{p['Project']}': no budget (will load as null)")
if p['Event date'].strip() and not parse_date(p['Event date']):
problems.append(f"{p['Record ID']}: unreadable date '{p['Event date']}'")
blocking = True
return problems, blocking, seen
Fix blocking issues at the source, in Airtable, and export again. Here the two projects were relinked to their current clients. Cleaning data inside the migration script instead means the fix disappears the next time someone exports.
Step 4: Load in one transaction, and reconcile
The loader upserts clients and projects by airtable_id, skips projects that haven’t changed since the last run, and rebuilds each changed project’s links, tags and attachments. It also lists projects that are in the database but no longer in the export, without deleting them.
def load(cur, clients, projects, canonical):
stats = {'inserted': 0, 'updated': 0, 'unchanged': 0}
client_ids = {}
for c in canonical.values():
cur.execute("""insert into clients (airtable_id, name, email, phone) values (%s, %s, %s, %s)
on conflict (airtable_id) do update set name = excluded.name, email = excluded.email, phone = excluded.phone
returning id""", (c['Record ID'], ' '.join(c['Name'].split()), c['Email'], c['Phone']))
client_ids[norm(c['Name'])] = cur.fetchone()[0]
for p in projects:
modified = datetime.fromisoformat(p['Last modified'].replace('Z', '+00:00'))
cur.execute('select id, modified_at from projects where airtable_id = %s', (p['Record ID'],))
row = cur.fetchone()
if row and row[1] >= modified:
stats['unchanged'] += 1
continue
cur.execute("""insert into projects (airtable_id, name, status, budget, event_date, modified_at)
values (%s, %s, %s, %s, %s, %s)
on conflict (airtable_id) do update set name = excluded.name, status = excluded.status,
budget = excluded.budget, event_date = excluded.event_date, modified_at = excluded.modified_at
returning id""",
(p['Record ID'], p['Project'], p['Status'], money(p['Budget']), parse_date(p['Event date']), modified))
pid = cur.fetchone()[0]
stats['updated' if row else 'inserted'] += 1
cur.execute('delete from project_clients where project_id = %s', (pid,))
for name in filter(None, p['Client'].split(',')):
cur.execute('insert into project_clients values (%s, %s)', (pid, client_ids[norm(name)]))
cur.execute('delete from project_tags where project_id = %s', (pid,))
for tag in filter(None, p['Tags'].split(',')):
cur.execute('insert into tags (name) values (%s) on conflict (name) do update set name = excluded.name returning id', (tag,))
cur.execute('insert into project_tags values (%s, %s)', (pid, cur.fetchone()[0]))
for filename, url in split_attachments(p['Attachments']):
cur.execute("""insert into attachments (project_id, filename, source_url) values (%s, %s, %s)
on conflict (project_id, filename) do update set source_url = excluded.source_url""", (pid, filename.strip(), url))
# Deletions: rows in the database that are no longer in the export are reported, never removed automatically
cur.execute('select airtable_id, name from projects where airtable_id <> all(%s)', ([p['Record ID'] for p in projects],))
stats['missing'] = cur.fetchall()
return statsThen it counts everything twice, once from the export and once from the database, and the run commits only if every line matches. The whole run is one transaction, so a mismatch or an error leaves the database exactly as it was.
def reconcile(cur, canonical, projects):
ids = [p['Record ID'] for p in projects]
expected = {
'clients': len(canonical),
'projects': len(projects),
'client links': sum(len(list(filter(None, p['Client'].split(',')))) for p in projects),
'tag links': sum(len(list(filter(None, p['Tags'].split(',')))) for p in projects),
'attachments': sum(len(split_attachments(p['Attachments'])) for p in projects),
'budget total': sum((money(p['Budget']) or Decimal(0)) for p in projects),
}
# compare only records that are in this export; reported deletions are checked separately
queries = {
'clients': 'select count(*) from clients',
'projects': 'select count(*) from projects where airtable_id = any(%s)',
'client links': 'select count(*) from project_clients join projects p on p.id = project_id where p.airtable_id = any(%s)',
'tag links': 'select count(*) from project_tags join projects p on p.id = project_id where p.airtable_id = any(%s)',
'attachments': 'select count(*) from attachments join projects p on p.id = project_id where p.airtable_id = any(%s)',
'budget total': 'select coalesce(sum(budget), 0) from projects where airtable_id = any(%s)',
}
ok = True
print('\nreconciliation export database')
for key, sql in queries.items():
cur.execute(sql, (ids,))
actual = cur.fetchone()[0]
match = Decimal(actual) == Decimal(expected[key])
ok &= match
fmt = (lambda v: f'{v:,.2f}') if key == 'budget total' else (lambda v: f'{v:,}')
print(f" {key:<18}{fmt(expected[key]):>14}{fmt(actual):>14} {'ok' if match else 'MISMATCH'}")
return ok
Step 5: Keep running it as a sync
Nobody should stop work for a migration. While people try the new screens, export and run the script every day. A week later in our demo, six projects had changed status, two new ones had been added and one had been deleted:

On cut-over day, switch Airtable to read-only, run the script one last time, check the reconciliation, and point people at the new app. Keep the base read-only for a few weeks as a reference.
What you get afterwards
Once the data is in proper tables, questions that took a pivot table and some patience become one query. Booked but unpaid work per client:
-- Booked but unpaid work per client: three linked tables in Airtable, one query here
select c.name as client,
count(*) as projects,
sum(p.budget) filter (where p.status in ('Booked', 'Delivered', 'Invoiced')) as unpaid_value,
max(p.event_date) as latest_event
from clients c
join project_clients pc on pc.client_id = c.id
join projects p on p.id = pc.project_id
group by c.name
order by unpaid_value desc nulls last
limit 8;
Three migrations we’ve done
Scalers, a recruitment start-up, ran its customer success work in Airtable until the volume of candidate and client data outgrew it. We moved the records into PostgreSQL, added OpenSearch for fast search, and built a Retool app with Slack alerts and automated workflows on top.
A hospitality and leisure business moved from Airtable to Supabase in five milestones, each one approved before we started the next: a relational schema designed from their bases, around 1,300 attachments moved, and an incremental sync so the team kept working throughout. Their new Retool apps are in daily use.
Instacoach’s team liked their Google Sheet, so we kept it and synced it with PostgreSQL, which now does the heavy lifting behind a Retool dashboard. Sometimes the best migration is one the team barely notices.
What shapes the cost
| Factor | Why it matters |
|---|---|
| Number of tables and links | Each linked-record field becomes a relationship to design, load and check. |
| Data quality | Duplicates and broken links have to be fixed at the source before cut-over. |
| Attachments | Files need copying to storage, with counts checked like everything else. |
| Automations and integrations | Every Zap, script or form that writes to the base needs a new home. |
| Screens | The team needs an app to work in on day one, usually Retool or a portal. |
Mistakes we’d avoid
Sources
Questions we often hear
Will we lose our Airtable attachments and linked records?
Not if it’s planned properly. Attachments move to file storage and links become real relationships, and every count is checked before the move is committed.
Airtable or Supabase: which is better?
Airtable is better for getting started and for small teams. Supabase is better once you need scale, proper permissions and an app on top. Many teams use Airtable first and move later.
Can we keep using Airtable during the migration?
Yes. The script runs as a sync, so the team keeps working in Airtable until the planned cut-over.





