Back to all postsA yellow paper diamond above a red paper triangle and a blue paper parallelogram, cut into the Airtable mark on a pale paper background

How to Import CSV Into Airtable

Airtable moves a CSV into a base from inside the base. A native upload reads a file up to 100MB. Copy and paste drops a block of spreadsheet cells into a grid. The CSV import extension maps columns to fields and merges on a field somebody picks. Sync integrations pull from Google Drive, GitHub Issues, Jira Cloud and Salesforce. Each of those runs from a seat in the base, and Airtable asks for Owner or Creator permissions before third-party data lands in an existing one. The grants manager sending you a quarter of applications sits in your product, holding an export from the system the foundation is leaving.

Their export arrives looking like this.

grants-q1-2026.csv
ABCDEFGH
1app_reforg_nameeinprogrammeamount_requestedsubmitted_ondecisionfocus_areas
2GR-2026-0118Harbor Youth Collective45-3810922Community Grants$25,00003/02/2026ApprovedFood security; youth ed
3gr-2026-0119 Rivera Food Alliance81-2299047Community Grants25 00017.02.2026APPROVEDNutrition; Housing
4GR-2026-0120Northside Literacy Trust26-4471158Rapid Response12,500.004 Mar 2026awardededucation youth; public health
236 rows not shown
241GR-2026-0357Elmgrove Arts Coalition47-9920164Community Grants1800028.04.2026DeclinedPublic health
1app_ref,org_name,ein,programme,amount_requested,submitted_on,decision,focus_areas2GR-2026-0118,Harbor Youth Collective,45-3810922,Community Grants,"$25,000",03/02/2026,Approved,Food security; youth ed3gr-2026-0119 ,Rivera Food Alliance,81-2299047,Community Grants,25 000,17.02.2026,APPROVED,Nutrition; Housing4GR-2026-0120,Northside Literacy Trust,26-4471158,Rapid Response,"12,500.00",4 Mar 2026,awarded,education youth; public health236 rows not shown241GR-2026-0357,Elmgrove Arts Coalition,47-9920164,Community Grants,18000,28.04.2026,Declined,Public health

Eight headers over 240 applications. app_ref, org_name and programme are the exporting system's words for fields the base calls Application ref, Organization and Program. One grant reference is lowercase and trails a space. One amount carries a dollar sign and a thousands comma, the next groups its thousands with a space, and the third writes cents. The submitted date appears in three shapes across three rows. The decision column says Approved, APPROVED and awarded against a field holding three fixed options. The focus areas column packs several values into one cell.

The grants manager drops that export into the importer inside your app. Updog Importer opens it in the browser, lines the headers up against your schema, and shows every value on screen. Your onComplete handler receives the rows. The handler cuts them into chunks and posts each chunk to a route you own. The route holds the Airtable token and upserts ten records a request.

Nothing in that chain runs on an Updog server.

The base the rows land in

Start by reading the base you are writing to. GET https://api.airtable.com/v0/meta/bases/{baseId}/tables returns every table, and a token carrying the schema.bases:read scope is enough to call it.

{
"tables": [
{
"id": "tblApplications1",
"name": "Applications",
"fields": [
{ "id": "fldRef000000001", "name": "Application ref", "type": "singleLineText" },
{ "id": "fldOrg000000002", "name": "Organization", "type": "singleLineText" },
{ "id": "fldEin000000003", "name": "EIN", "type": "singleLineText" },
{ "id": "fldPrg000000004", "name": "Program", "type": "singleLineText" },
{ "id": "fldAmt000000005", "name": "Amount requested", "type": "currency" },
{ "id": "fldSub000000006", "name": "Submitted on", "type": "date" },
{
"id": "fldDec000000007",
"name": "Decision",
"type": "singleSelect",
"options": {
"choices": [
{ "id": "selApproved001", "name": "Approved" },
{ "id": "selDeclined002", "name": "Declined" },
{ "id": "selUnderRev003", "name": "Under review" }
]
}
},
{
"id": "fldFoc000000008",
"name": "Focus areas",
"type": "multipleSelects",
"options": {
"choices": [
{ "id": "selFood0000001", "name": "Food security" },
{ "id": "selYouth000002", "name": "Youth education" },
{ "id": "selHousing0003", "name": "Housing" },
{ "id": "selHealth00004", "name": "Public health" }
]
}
}
]
}
]
}

Field ids start with fld and sit beside the names. A currency field takes a number, and a date field takes an ISO date string. Decision is a single select and Focus areas is a multiple select, each holding a choices array. With typecast off, those choice names are the values the two fields accept, so copying them into your column schema keeps the browser and the base agreed on one vocabulary.

The schema in the browser

The columns array is that base written for the person looking at the file. Each column gives the field a title, an editor that decides how the cell is typed, and validators that flag bad values.

import type { DataEditorColumn } from "@updog/data-editor";
const DECISIONS = ["Approved", "Declined", "Under review"];
const FOCUS_AREAS = [
"Food security",
"Youth education",
"Housing",
"Public health",
];
export const columns: DataEditorColumn[] = [
{
id: "appRef",
title: "Application ref",
size: 150,
transformer: (value) => String(value).trim().toUpperCase(),
validators: [{ type: "required" }, { type: "unique" }],
},
{
id: "organization",
title: "Organization",
size: 220,
validators: [{ type: "required" }],
},
{
id: "ein",
title: "EIN",
size: 130,
validators: [{ type: "regex", pattern: "^\\d{2}-\\d{7}$" }],
},
{
id: "program",
title: "Program",
size: 170,
validators: [{ type: "required" }],
},
{
id: "amountRequested",
title: "Amount requested",
size: 160,
editor: { type: "number" },
validators: [{ type: "number", min: 0, decimalPlaces: 2 }],
},
{
id: "submittedOn",
title: "Submitted on",
size: 140,
editor: { type: "date" },
validators: [{ type: "date" }],
},
{
id: "decision",
title: "Decision",
size: 150,
editor: { type: "select", options: DECISIONS, enableCustomValue: false },
validators: [{ type: "oneOf", values: DECISIONS }],
},
{
id: "focusAreas",
title: "Focus areas",
size: 260,
editor: {
type: "multiselect",
options: FOCUS_AREAS,
enableCustomValue: false,
delimiter: ";",
},
validators: [{ type: "oneOf", values: FOCUS_AREAS }],
},
];

The date editor reads all three date shapes in that column. The 17.02.2026 in the second row carries a day above 12, which settles the column as day-first and lands 03/02/2026 as 2026-02-03. The third row writes 12,500.00 with both separators, and the last separator is the decimal, which settles the column as comma-grouped. On that verdict the number editor peels the dollar sign, collapses the space grouping and drops the thousands comma, so $25,000 and 25 000 both arrive as 25000. The select editor with enableCustomValue off holds Decision to the three choices the base published. The multiselect editor with a ; delimiter turns Food security; youth ed into two values, and every cell in that column stores an array.

Those two editors are what keeps typecast out of the request later. The oneOf validators behind them apply per element on the multiple select, so one bad value in a list marks the row.

The transformer on appRef runs before the value enters the store, so gr-2026-0119 lands as GR-2026-0119, uppercase and trimmed. That matters because the same string becomes the merge key Airtable matches on.

The mount ties the whole thing together.

<DataEditor<Application>
apiKey="your-license-key"
variant="uploader"
open={open}
onClose={closeImporter}
columns={columns}
primaryKey="appRef"
blockSubmitOnError
synonyms={{
columns: { organization: ["org_name", "legal name", "grantee"] },
values: { Approved: ["awarded", "granted"] },
}}
onComplete={onComplete}
/>

primaryKey decides how an imported row meets a row already in the grid, and values are compared after surrounding whitespace is trimmed. blockSubmitOnError keeps submit disabled while any row carries a validation error, so nothing reaches your route that a validator already rejected. The snippets here are React. The web component build takes the same props, so a Vue, Angular or Svelte app writes the same schema and the same handler. For the install and the modal wiring underneath this snippet, see how to import a CSV file into a React app.

The words the file does not use

Column matching scores a header against every column id and title, after lowercasing each one and dropping its spaces, underscores, hyphens and dots. app_ref comes out of that as appref and so does the column id appRef, which is a top-tier hit. programme and Program match on containment, because the shorter normalized string sits inside the longer one. org_name scores zero against organization, which is the pair the synonyms prop is there to cover.

Value matching runs the same ladder against the option list. Approved and APPROVED both land as exact hits, because case goes with the lowercasing. awarded reaches Approved from the synonyms values table, since it shares no run of letters with any option. youth ed lands because youtheducation contains it. education youth lands on word overlap, since both words appear in Youth education in the other order. Nutrition scores zero against all four options, so the person maps it by hand in the value-matching step.

That hand-made pair comes back on the result as learnedSynonyms, ready to store and feed back through synonyms next time. How to remember CSV import mappings between uploads follows that pair from the result back into the prop.

The handler that chunks and throws

When the person submits, Updog Importer hands your handler the applications grouped by source, each row flagged isNew, isChanged, isDeleted and isValid. Rows nobody touched stay out. One grants manager can bring three exports into a single import, and each one lands as its own source entry, so the handler flattens the list before slicing it.

import type { DataEditorResult } from "@updog/data-editor";
const CHUNK_SIZE = 1000;
const onComplete = useCallback(async (result: DataEditorResult<Application>) => {
const rows = result.sources
.flatMap((source) => source.rows)
.filter((entry) => entry.isValid && !entry.isDeleted)
.map((entry) => entry.row);
for (let start = 0; start < rows.length; start += CHUNK_SIZE) {
const response = await fetch("/api/grants/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rows: rows.slice(start, start + CHUNK_SIZE) }),
});
if (!response.ok) {
const failure = await response.json();
throw new Error(failure.message);
}
}
}, []);

A thousand rows a request is a conservative choice of ours, sitting well inside the body your own route accepts. On Vercel a function body stops at 4.5 MB, and anything larger comes back as 413: FUNCTION_PAYLOAD_TOO_LARGE.

{"appRef":"GR-2026-0118","organization":"Harbor Youth Collective","ein":"45-3810922","program":"Community Grants","amountRequested":"25000","submittedOn":"2026-02-03","decision":"Approved","focusAreas":["Food security","Youth education"]}

One application row of these eight fields serializes to 238 bytes, so a thousand of them reach 232 KB. Measure your own widest application row and multiply again before you raise CHUNK_SIZE. A table of forty text fields changes the answer.

Throw when the route rejects a chunk. Updog waits on your handler and empties the editor as soon as it resolves. A handler that swallows its own error and returns looks like success, so the grid empties with the applications unwritten. A thrown error keeps every application, every mapping and every correction on screen, so the person presses submit again on rows still in front of them. Copy anything off the result inside the handler, because once the promise resolves the editor lets go of its rows, its sources, its history and its learned synonyms.

The route that holds the token

One route holds the token, and the browser reaches no further than that route.

import { NextResponse } from "next/server";
const BATCH = 10;
const PACE_MS = 250;
const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms));
export async function POST(request: Request) {
const session = await getVerifiedSession();
if (!session) {
return NextResponse.json({ message: "Not signed in" }, { status: 401 });
}
const { baseId, tableId, token } = await getAirtableGrant(session.orgId);
const { rows } = await request.json();
const records = rows.map((row) => ({
fields: {
"Application ref": row.appRef,
Organization: row.organization,
EIN: row.ein,
Program: row.program,
"Amount requested":
row.amountRequested === "" ? null : Number(row.amountRequested),
"Submitted on": row.submittedOn,
Decision: row.decision,
"Focus areas": row.focusAreas,
},
}));
for (let start = 0; start < records.length; start += BATCH) {
const response = await fetch(
"https://api.airtable.com/v0/" + baseId + "/" + tableId,
{
method: "PATCH",
headers: {
Authorization: "Bearer " + token,
"Content-Type": "application/json",
},
body: JSON.stringify({
performUpsert: { fieldsToMergeOn: ["Application ref"] },
records: records.slice(start, start + BATCH),
}),
},
);
if (!response.ok) {
const body = await response.json();
const detail =
typeof body.error === "string" ? body.error : body.error.message;
return NextResponse.json(
{ message: detail, written: start },
{ status: response.status },
);
}
await sleep(PACE_MS);
}
return NextResponse.json({ written: records.length });
}

Airtable authenticates on an Authorization: Bearer header, and the token needs the data.records:write scope with the base added to it as a resource. A personal access token suits an integration you run for yourself or one client, and Airtable says such a token acts as your user account. For a product where each foundation grants your service access to their own base, Airtable recommends OAuth access tokens instead. getVerifiedSession() stands in for your own server-side authentication check, and getAirtableGrant() for wherever you keep that foundation's base id, table id and token.

The official airtable package creates, updates, replaces and destroys records. It has no upsert, in its source or its changelog, so this route calls the endpoint with fetch and posts one JSON body. Its README also warns against putting the key on a web page, which is the same reason the browser above talks only to your own route.

Batching handles up to ten records a request, which is why BATCH is 10. The API allows five requests a second per base, and going over returns a 429 with a 30 second wait before anything succeeds. A 250 millisecond sleep between batches holds four requests a second, inside the published limit with room for a slow response. A thousand rows is a hundred requests, so the sleeps alone add twenty five seconds, well inside the 300 second default duration of a Vercel function. The same chain ending in a Postgres table is in how to import CSV into Supabase, where the ceiling is a statement timeout.

Free and Team plans carry a monthly call cap on top of that, counted per workspace. Free allows 1,000 API calls a month, then blocks the rest of the month once a one-time grace period runs out. Team allows 100,000, after which calls slow to two requests a second until the month resets.

A value the field has never held

A single select accepts a string, and a multiple select accepts an array of strings. Airtable's field model puts the condition plainly, "if the choice string does not exactly match an existing option, the request will fail with an INVALID_MULTIPLE_CHOICE_OPTIONS error unless the typecast parameter is enabled". Airtable's troubleshooting page describes that 422 as a select field option that does not yet exist in the field arriving in the request body.

typecast changes what happens next. Airtable calls it "best-effort automatic data conversion from string values", disabled by default "to ensure data integrity", and the field model adds that an enabled typecast creates a new choice when nothing matches exactly.

{
"typecast": true,
"performUpsert": { "fieldsToMergeOn": ["Application ref"] },
"records": [{ "fields": { "Decision": "awarded" } }]
}

That body writes an option called awarded into the Decision field of a base the foundation's team works in every day. Their views, their filters and their reports now carry two words for one outcome. The option stays after the import. Leaving typecast out of the request is what stops the API from editing the field, and that works because the option list was settled upstream. The closed select editor and the oneOf validator hold every decision inside Approved, Declined and Under review while the person can still see the row it came from.

Upsert on a merge field

performUpsert turns the update endpoint into an upsert. fieldsToMergeOn is used as an external id to match records for updates, and a record that matches nothing is created. The array takes one to three field names or ids, and Airtable rules out computed fields, leaving number, text, long text, single select, multiple select and date. Application ref is single line text, so it qualifies.

A PATCH updates only the fields in the request, where a PUT clears every cell value left out. When several existing records match one incoming record, the request fails, which makes a clean merge field worth more than a clever one. The response comes back with createdRecords and updatedRecords arrays, so the route can report which half of the chunk was new.

Chunks that already landed stay landed. Sending one again matches the same records on Application ref and rewrites them with the same values, so a retry after a failed chunk costs a rewrite of what already exists.

Field names and field ids

The request body keys cell values by either field name or field id, and both have a way to break. A field id is tied to a specific field instance, so a field somebody deletes and recreates with the same name, type and position comes back with a new id, and the request answers UNKNOWN_FIELD_NAME with a 422. A field name breaks the day somebody renames the field in the base.

Airtable's own troubleshooting page picks a side, "where possible, prefer referencing fields by name rather than by ID in third-party integrations, since field names are more stable across day-to-day edits". The route above follows that. Reading the base schema before each import covers the other half, because a name that has moved shows up in the response before a single record is written.

What Airtable's own import already does

Updog Importer integrates with nobody. There is no Airtable connector, no destination list, no webhook and no server of ours. onComplete gives your code an object, and the route between it and the base is yours to write.

Airtable already ships its own import for the other case. The native upload takes a CSV up to 100MB into the base. The CSV import extension runs on all paid plans, matches columns to fields by name, and merges on a field the base owner picks, comparing the table against the file and updating the values it finds. It stops at 25,000 rows and 5MB, and its Create missing select options toggle needs Owner or Creator permissions on the base. When the file belongs to whoever owns the base, those are the shorter paths. The whole chain above exists for an export owned by the foundation, arriving in a browser session your app issued. Client-side and server-side CSV import lays out which jobs suit each of the two models.

The dropdown nobody widened

You read a base schema, wrote eight columns with two closed option lists, added a handler that chunks and throws, and stood up one route that holds a token and paces itself. The export never leaves the machine that opened it. The rows travel from your own front end to your own route, and from there into Airtable, and the only party you added to the chain is yourself.

The foundation's team opens the base after that import and finds Approved where they left it. Nothing new appeared in the dropdown, because nothing in the chain was allowed to invent it.