airtable2 is an httr2-based R client for the Airtable REST API. It provides:
air_*) for reading, writing,
upserting, and syncing records with automatic pagination, type coercion,
and schema caching.at_*) that map directly to Airtable
API endpoints for advanced use.air_dump() /
air_restore()) for full base exports.Other vignettes:
vignette("special-types", package = "airtable2") —
working with Airtable’s rich column types (multi-select, attachments,
collaborators, linked records).vignette("metadata-backup", package = "airtable2") —
inspecting schema, pushing metadata changes, and performing full base
backups.Install from GitHub using pak or remotes:
# From R-universe (recommended)
install.packages("airtable2", repos = c("https://noamross.r-universe.dev", "https://cloud.r-project.org"))
# From GitHub (or use pak::pkg_install() or renv::install())
remotes::install_github("noamross/airtable2")airtable2 uses Airtable Personal Access Tokens
(PATs). OAuth is not supported by airtable2; you
create tokens yourself at https://airtable.com/create/tokens.
When creating a token, grant at minimum the following scopes depending on what you need:
| Scope | Required for |
|---|---|
data.records:read |
Reading records |
data.records:write |
Writing / upserting / deleting records |
schema.bases:read |
Type coercion, schema inspection, air_schema() |
schema.bases:write |
Creating bases, tables, fields |
webhook:manage |
(Optional) Webhooks |
For most workflows, enabling all scopes on the workspaces you need is simplest.
There are three ways to provide your token, checked in this order:
.token = "patXXXXX" to any air_*
function.options(airtable2.token = "patXXXXX") for the session.AIRTABLE_API_KEY=patXXXXX in your shell or
.Renviron.The recommended approach for persistent credentials is
.Renviron (or .env for multilingual projects),
which keeps tokens out of your scripts:
# Add these lines to ~/.Renviron (run usethis::edit_r_environ() to open it)
AIRTABLE_API_KEY = patXXXXXXXXXXXXXX
AIRTABLE_WORKSPACE_ID = wspXXXXXXXXXXXXXX
AIRTABLE_BASE_ID = appXXXXXXXXXXXXXX # convenient if all your work is one one baseNever hardcode tokens in scripts or check them into version control.
To set validate a token interactively, use
air_set_token(), which confirms the token works and stores
it for the session:
air_set_token("patXXXXXXXX")
# Token set. Authenticated as "[email protected]".Most air_* functions accept base_id = NULL
and will use the session default when none is supplied. Set it once per
session:
The same resolution order applies to base_id:
getOption("airtable2.base_id").Sys.getenv("AIRTABLE_BASE_ID").Run at_sitrep() to see who you are authenticated as and
which bases are accessible:
at_sitrep()
# ── Airtable situation report ───────────────────────────────────────────────
# User: [email protected] (usrXXXXXX)
# Scopes: (not exposed by this token type)
# Accessible bases: 3
# My Base (appXXXXXX) [create]
# Shared Base (appYYYYYY) [read]
# ...Airtable does not expose workspace IDs through its API for
non-enterprise accounts. To find yours, open Airtable in a browser — the
URL looks like https://airtable.com/wspXXXXXXXXXXXXXX/....
The wspXXXXXXXXXXXXXX portion is your workspace ID. You
will need this for air_restore() and
at_create_base().
Free Airtable plans cap each workspace at approximately 1,000 API calls per month. Complex operations (pagination, schema fetches for type coercion, batch writes) can each consume many calls. The package keeps a best-effort on-disk counter per workspace.
# Check how many calls you have used this month
air_api_usage()
# Airtable API usage for wspXXXXXX
# 47 API calls since start of month (2026-06-01T00:00:00Z)
# Free/team plan ceiling: ~1000 calls per workspace per monthTo disable counting (e.g., in automated scripts where you track usage elsewhere):
DELETE /meta/bases/{baseId} returns 403 on free and team
plans. There is no programmatic way to delete bases. Bases, tables, and
most fields must be deleted via the Airtable web interface.
Airtable omits unchecked checkbox fields from API responses rather
than returning FALSE. air_read() fills these
in automatically using the schema, so checkbox columns always contain
TRUE/FALSE with no missing values.
Formula, rollup, lookup, count, autoNumber,
createdTime, lastModifiedTime,
createdBy, and lastModifiedBy fields are
read-only. air_write(), air_upsert(), and
air_sync() automatically detect these from the schema and
silently exclude them from write payloads, so you can safely pass a full
tibble (including computed columns) and the package handles the
rest.
The examples below cover the full airtable2 workflow. All code uses
eval = FALSE because vignettes are built without a live
Airtable token. To follow along, create a demo base first (see Step
2).
Set your token and default base, then confirm access:
air_demo_setup() creates a small base that exercises all
of airtable2’s special types. You need your workspace ID (see the
section above on finding it).
demo_base_id <- air_demo_setup(
workspace_id = Sys.getenv("AIRTABLE_WORKSPACE_ID")
)
air_set_base(demo_base_id)Open the demo base in a browser to see its structure and watch it update.
air_read() auto-paginates, fetches the table schema for
type coercion, and returns a tibble. Every result includes
airtable_id (Airtable’s internal record ID) and
airtable_created_time.
artists <- air_read("Artists", demo_base_id)
artists
# A tibble: 15 × 10
# airtable_id airtable_created_time Name Age Active Role Disciplines `Member Since` Email
# <chr> <dttm> <chr> <dbl> <lgl> <chr> <list> <date> <chr>
# 1 recABCDEFGH 2026-05-01 10:00:00 Zara Okonkwo 33 TRUE Guerilla Muralist <chr[2]> 2022-03-15 [email protected]
# ...Fields with multiple values (multi-select, linked records,
attachments) are returned as list-columns. See
vignette("special-types", package = "airtable2") for how to
work with them.
You can filter and sort at read time:
air_write() creates new records. Columns not present in
the table generate an error by default; set
add_fields = "yes" to create them automatically as
singleLineText fields, or add_fields = "warn"
to silently drop unknown columns.
new_artists <- data.frame(
Name = c("Dana Reyes", "Eli Sato"),
Age = c(28L, 34L),
Active = c(TRUE, TRUE),
Role = c("Guerilla Muralist", "Kinetic Sculptor")
)
ids <- air_write(new_artists, "Artists", demo_base_id)
# Created 2 records.
# Add a field that doesn't exist yet
new_artists$Notes <- "Added via API"
ids <- air_write(new_artists, "Artists", demo_base_id, add_fields = "yes")
# Creating field "Notes" in "Artists".
# Created 2 records.Computed fields in your data frame are silently dropped before writing.
air_upsert() uses Airtable’s native upsert endpoint:
records matching merge_on are updated; records without a
match are created.
updated <- data.frame(
Name = c("Zara Okonkwo", "Dmitri Volkov", "New Collaborator"),
Age = c(35L, 42L, 27L)
)
result <- air_upsert(updated, "Artists", merge_on = "Name", base_id = demo_base_id)
# Upsert complete: 1 created, 2 updated.
result$created # record IDs of new records
result$updated # record IDs of updated recordsair_sync() is a more aggressive diff-based sync: it
reads the current table, hashes non-key fields to detect changes,
upserts only the changed rows, and optionally (but by default!) deletes
rows that are absent from your local data.
desired <- data.frame(Name = c("Zara Okonkwo", "Dmitri Volkov"), Age = c(35L, 42L))
result <- air_sync(desired, "Artists", key = "Name", base_id = demo_base_id, delete_missing = TRUE)
# Sync complete: 0 created, 2 updated, 13 deleted, 0 unchanged.Both functions exclude computed fields automatically.
air_left_join() fetches a remote table and joins it with
a local data frame, so you can enrich local data with Airtable fields
(or vice versa) without pulling everything into memory first.
scores <- tibble::tibble(
Name = c("Zara Okonkwo", "Dmitri Volkov", "Sun-Li Park"),
Score = c(98, 85, 92)
)
enriched <- air_left_join(scores, "Artists", demo_base_id, by = "Name")
# Joining on "Name".air_inner_join() and air_full_join() are
also available.
Download attachments to disk or as in-memory blobs:
# Download attachment metadata only (default for air_read)
df <- air_read("Projects", demo_base_id)
# df$Files is a list-column of attachment metadata
# Download files to disk
df_with_files <- air_read(
"Projects",
demo_base_id,
attachments = "file",
attachment_dir = "downloads/"
)
# Or fetch attachments for specific records
att <- air_read_attachments(
demo_base_id,
"Projects",
field = "Files",
dest = "file",
dir = "downloads/"
)
att
# A tibble: 5 × 6
# airtable_id filename url size type local_path
# <chr> <chr> <chr> <int> <chr> <chr>
# 1 recABCDEFG report.pdf https://… 24601 application/pdf downloads/report.pdfUpload attachments:
to_upload <- tibble::tibble(
airtable_id = c("recABCDEFG"),
file_path = c("new_report.pdf")
)
air_write_attachments(demo_base_id, "Projects", field = "Files", data = to_upload)
# Uploading 1 attachment...
# Upload complete.Attachment downloads are parallelised by default (up to 5 concurrent)
using httr2::req_perform_parallel(). Disable with
options(airtable2.parallel = FALSE).
Multi-select, linked records, collaborators, attachments, and barcodes are all returned as list-columns with custom print methods, so they display compactly in tibble output but retain their full structure.
# Read Artists - Disciplines is a list-column (air_multiselect)
artists <- air_read("Artists", demo_base_id)
artists$Disciplines
# <air_multiselect[15]>
# [1] "Mural; Street Art" "Sculpture; Community" ...
# Flatten to a character column
air_flatten_multiselect(artists$Disciplines)
# [1] "Mural; Street Art" "Sculpture; Community" ...
# Upload with flat strings — auto-expanded (no manual air_expand_*() needed):
new_row <- data.frame(Name = "New Artist", Disciplines = "Mural; Sculpture")
air_write(new_row, "Artists", demo_base_id)See vignette("special-types", package = "airtable2") for
full coverage of all special types and their flatten/expand helpers.
air_schema() returns a tidy tibble of tables and
fields:
schema <- air_schema(demo_base_id)
schema
# A tibble: 3 × 4
# table_id table_name table_description fields
# <chr> <chr> <chr> <list>
# 1 tblXXXXXXXXXX Artists NA <tibble[8 × 4]>
# 2 tblXXXXXXXXXX Projects NA <tibble[7 × 4]>
# 3 tblXXXXXXXXXX Grants NA <tibble[6 × 4]>
# Drill into a table's fields
schema$fields[[1]]
# A tibble: 8 × 4
# id name type description
# <chr> <chr> <chr> <chr>
# 1 fldXXXXXXXXXX Name singleLineText Full name of the artist...
# ...air_meta() returns the same information as a flat tibble
(one row per field), which is easier to filter and edit:
meta <- air_meta(demo_base_id)
meta[meta$table_name == "Artists", c("field_name", "field_type")]
# A tibble: 8 × 2
# field_name field_type
# <chr> <chr>
# 1 Name singleLineText
# 2 Age number
# 3 Active checkbox
# 4 Role singleSelect
# 5 Disciplines multipleSelects
# 6 Member Since date
# 7 Email emailSee vignette("metadata-backup", package = "airtable2")
for how to push metadata changes back to Airtable and keep bases
self-documenting.
air_dump() exports an entire base — schema plus all
table data — in your choice of format:
# Full JSON backup with attachment files
air_dump(demo_base_id, dir = "backup/", attachments = "file")
# Dumping 3 tables from base "bollardsforart_demo"...
# Reading "Artists"...
# Reading "Projects"...
# Reading "Grants"...
# Dump written to backup/
# Quick in-memory dump without downloading attachments
snapshot <- air_dump(demo_base_id, format = "list", attachments = "meta")
# snapshot$schema — list of table + field definitions
# snapshot$Artists — tibble of Artists records
# snapshot$Projects — tibble of Projects records
# snapshot$Grants — tibble of Grants recordsair_restore() recreates a base from a dump. You must
specify the workspace to restore into, or use the default one from the
session.
new_base_id <- air_restore(
"backup/",
workspace_id = Sys.getenv("AIRTABLE_WORKSPACE_ID"),
base_name = "Restored Base"
)
# Creating base "Restored Base"...
# Adding fields...
# Inserting records...
# Recreated 2 link fields and 0 dependent fields.
# Re-linked records in 1 table.
# Restore complete. New base ID: appYYYYYYYY.By default (restore_linked_fields = TRUE), linked-record
fields and their cell values are restored automatically. Set
restore_linked_fields = FALSE to skip this step.
Note: some field types (rollup, lookup,
lastModifiedTime, etc.) cannot be created via the API;
air_restore() warns about them and you will need to
recreate those fields manually in the web UI.
See vignette("metadata-backup", package = "airtable2")
for the full backup/restore workflow.
Check how many calls you have used this month:
air_api_usage()
# Airtable API usage for wspXXXXXXXXXXXXXX
# 127 API calls since start of month (2026-06-01T00:00:00Z)
# Most recent call: 2026-06-04T14:22:10Z
# Free/team plan ceiling: ~1000 calls per workspace per monthThe counter is per-workspace, resets at the start of each UTC month,
and is stored on disk in
tools::R_user_dir("airtable2", "data"). It counts
best-effort (failed requests may still be counted if they reached
Airtable’s servers). Opt out with
options(airtable2.count_api = FALSE).
Bases cannot be deleted via the API on free and team plans. After a demo or experiment, delete the base manually in the Airtable web UI at https://airtable.com.
vignette("special-types", package = "airtable2") —
detailed guide to list-column types and flatten/expand helpers.vignette("metadata-backup", package = "airtable2") —
schema inspection, metadata push, and full backup/restore
workflows.help(package = "airtable2") or on the pkgdown site.llms.txt file is included
in the package installation directory
(system.file("llms.txt", package = "airtable2"))
summarising the package API in a format optimised for language
models.