Your Airtable formulas are the migration. Everything else is a CSV.
Getting your records out of Airtable is not the hard part. There is a button for it and the rows come out fine.
The hard part is that a CSV export gives you what each formula column last said, not what it means. Days to run arrives as 91 — a number, frozen, in a column that used to update itself. Multiply that by however many formula columns two years of use accumulated, across however many bases, and you have the real migration: not moving data, but rebuilding logic from memory while looking at the answers it used to produce.
The failure is quiet, which is why it stalls migrations
It does not announce itself. The data lands, people start using the new tool, and three weeks later somebody notices a column has been frozen since the switch. Nobody knows which columns those are, because the export did not distinguish a formula from a number. So somebody opens Airtable in a second window and starts retyping expressions.
That work is unbounded and boring, which means it gets postponed, which means the two systems run in parallel, which is how a migration reverts.
What actually happens to an Airtable formula
Appdor reads 7 formula dialects and compiles them into its own expression language, so the expression moves and stays live. Here is what that looks like on real formulas — left column in, right column out:
| Airtable | Appdor |
|---|---|
IF({Status} = "Done", 1, IF({Status} = "Blocked", -1, 0)) | IF({Status} = "Done", 1, IF({Status} = "Blocked", -1, 0)) |
DATETIME_DIFF({Due}, {Start}, 'days') | DATETIME_DIFF({Due}, {Start}, "days") |
DATETIME_FORMAT({Due}, 'YYYY-MM-DD') | DATETIME_FORMAT({Due}, "YYYY-MM-DD") |
ROUND({Hours} * 145, 2) | ROUND({Hours} * 145, 2) |
SWITCH({Tier}, "Priority", 2, "Standard", 1, 0) | SWITCH({Tier}, "Priority", 2, "Standard", 1, 0) |
REGEX_EXTRACT({Project}, '^[A-Z]{3}') | REGEXEXTRACT({Project}, "^[A-Z]{3}") |
ARRAYJOIN({Tags}, ", ") | JOIN({Tags}, ", ") |
RECORD_ID() | ROWID() |
CREATED_TIME() | CREATEDON() |
Two things are worth noticing, and neither is the flashy one.
The first five rows are boring: the nested conditional, the date arithmetic, the rounding and the SWITCH come across effectively unchanged, with quoting normalised. That is the common case, and the common case being boring is the entire product claim.
The last four are the interesting ones — REGEX_EXTRACT loses its underscore, ARRAYJOIN becomes JOIN, RECORD_ID becomes ROWID, CREATED_TIME becomes CREATEDON. These are the ones a person retyping by hand gets wrong, because they look fine either way and only fail at evaluation.
What it refuses to do
Some Airtable functions have no counterpart, and the compiler says so rather than guessing. PREVIOUS_VALUE({Status}) depends on Airtable keeping the prior value of a cell — a model Appdor does not have — and it comes back like this:
Unknown function "PREVIOUS_VALUE" in dialect airtable.
A formula that does not parse at all gets a position, not a shrug:
Could not parse airtable formula: Unexpected character '@' at position 28
That list of refusals is your actual migration backlog, and it is normally far shorter than the inventory of formula columns. It is also the part worth insisting on when you evaluate anything else in this category: a migration tool that reports no losses has not found none, it has stopped looking.
The rest of the base moves too, and it also reports
Formulas are the headline, but they are not the only thing a CSV drops.
- Linked records. A link cell holds Airtable's own
rec…ids, and those mean nothing once rows have new ids. So the import runs in two passes: write every row, build a map of old id to new id, then go back and rewrite the links. Doing it in one pass is the obvious implementation and it silently produces a base where every cross-record link points at nothing. - Saved views. Filters and sorts are rebuilt against the new columns. A clause that cannot be expressed is not dropped quietly — in a real run last week, a view filtered on a checkbox lost its clause and arrived showing every row, and the report raised it as blocked rather than as a note. A view that arrives unfiltered shows more rows than it should, and for a view somebody shares, that is a disclosure rather than a cosmetic loss.
- Buttons, interfaces and automations. These have no equivalent. The button arrives as text, and the report says the type had no equivalent rather than leaving a column that looks right and does nothing.
Both import routes show you what they are about to do before anything is written, and both name what they could not carry — the formulas they translated, the field types that fell back to text, and the ones that failed outright. They are not identical: the pasted-export route ends in a full fidelity report that sorts every column into carried exactly, approximated, unsupported or dropped, and adds findings about saved views and relationships that the token route's preview does not raise. Both tell you where the losses are; one of them is more thorough about it.
The order that works
- Inventory the formula columns before you migrate, while you still have the old system to read. This is twenty minutes and it is the step people skip.
- Import and read the report. Nothing is written until you have. The refusals list is the backlog; the blocked items are the ones that change what people see.
- Rebuild the refusals by hand. There will be some. There are always some.
- Diff a sample of computed values against Airtable before you switch anyone over. This is the only step that proves the migration worked, and it is the other one people skip.
How the import gets your base
For completeness, since this is the question every thread about this asks: you make a read-only personal access token in Airtable — schema.bases:read and data.records:read — paste it into Settings → Import, and Appdor lists your bases so you can pick one. The token goes straight to the server and never stays in your browser; it is discarded after the request unless you ask us to remember it, in which case it is encrypted at rest and only its last four characters are ever shown back to you. There is no OAuth flow and nobody should tell you there is one click.
There is also a paste route, for a base you no longer have access to or an export somebody sent you.