Airtable doesn't have a button that downloads every attachment in a base. A CSV export doesn't help either: attachment fields come out as a file name and a link, and Airtable says those links expire after a few hours. To keep the files, you have to download them yourself, soon after reading the links.
Why the links expire
Since late 2022, Airtable serves attachments through temporary download URLs. Airtable promises that a link works for at least 2 hours after you receive it, and may change the exact window. Each new API call, automation run or CSV export returns fresh links, so any method below has to download the files right after it reads the records.
The same rule breaks other things: images embedded in emails or websites from Airtable links stop loading, and old CSV exports point at files you can no longer reach.
Option 1: one at a time
Expand a record, hover over the file name and select the download icon. This is fine for a few files and impractical for a whole table.
Option 2: a Marketplace extension or Airtable's script
Airtable's support article on attachment fields suggests the Scripting extension with a script that writes curl download commands for a record, a view or a whole table. Run the commands in a terminal within two hours. Airtable notes the script "will likely have limitations or bugs", so check the count of files you get against the count in the table.
Third-party extensions such as Attachment and Image Exporter and Bulk Download Attachments do the same through a dialog. Check what they do with files that share a name before you rely on them.
Option 3: a script using the Airtable API
If you're comfortable running a Node.js script, this one downloads every attachment in a table into one folder per record, so files with the same name in different records don't overwrite each other.
- Create a personal access token with the
data.records:readscope and access to the base. - Find the base ID (it starts with
app) in the base's URL. - Save the script as
download-attachments.mjsand run it with Node.js 20 or later:
AIRTABLE_TOKEN=pat... node download-attachments.mjs appXXXXXXXXXXXXXX "Table name"
// Downloads every attachment in one Airtable table into attachments/<record ID>/.
import { createWriteStream } from "node:fs";
import { mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
const [baseId, table] = process.argv.slice(2);
const headers = { Authorization: `Bearer ${process.env.AIRTABLE_TOKEN}` };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let offset;
let count = 0;
while (true) {
const url = new URL(`https://api.airtable.com/v0/${baseId}/${encodeURIComponent(table)}`);
if (offset) url.searchParams.set("offset", offset);
const response = await fetch(url, { headers });
if (response.status === 429) {
await sleep(30_000); // Airtable's rate limit asks for a 30-second pause.
continue;
}
if (!response.ok) throw new Error(`Airtable answered ${response.status}`);
const page = await response.json();
for (const record of page.records) {
for (const value of Object.values(record.fields)) {
if (!Array.isArray(value)) continue;
for (const file of value) {
if (!file?.id?.startsWith("att") || !file.url) continue;
const folder = join("attachments", record.id);
const path = join(folder, `${file.id}-${file.filename.replace(/[/\\]/g, "_")}`);
await mkdir(folder, { recursive: true });
const download = await fetch(file.url);
if (!download.ok) throw new Error(`Download failed for ${file.id}`);
await pipeline(Readable.fromWeb(download.body), createWriteStream(path));
if ((await stat(path)).size !== file.size) throw new Error(`Incomplete file ${path}`);
count += 1;
}
}
}
offset = page.offset;
if (!offset) break;
await sleep(250); // Stay under 5 requests per second.
}
console.log(`Downloaded ${count} files.`);
The script reads a page of 100 records, downloads that page's files straight away, then asks for the next page, so no link sits unused for long. It checks each file's size against the size Airtable reports. Run it once per table.
Option 4: a backup service
Backup services read the records and download the files in one pass, on a schedule. Check two things before you choose one: whether it saves the files themselves or only their links, and whether it saves an unchanged file once or again every day. The backup tools comparison covers the main ones.
Keep the files connected to their records
A folder of files is much less useful without knowing which record each one belongs to. Whichever method you use:
- Save files by record ID, not only by file name. Different records often have files called
image.pngorinvoice.pdf. - Keep the records themselves next to the files, as CSV and ideally JSON with record IDs. See how to export an entire base.
- Compare the number of files and their sizes with what Airtable reports. A download that stopped halfway can look fine at a glance.
Size limits to plan for
Airtable allows single files up to 5 GB (1 GB on the Free plan), and limits total attachment storage per base: 1 GB on Free, 20 GB on Team, 100 GB on Business and 1,000 GB on Enterprise Scale. A base near its limit can mean a long download and a lot of storage, so check where the files are going before you start.
Sources: Airtable's support articles on attachment URLs and attachment fields, and the Web API rate limits, checked October 17, 2026.