Airtable 422 INVALID_MULTIPLE_CHOICE_OPTIONSの原因

n8nからAirtableの単一選択の列へ書き込んだら、422 と INVALID_MULTIPLE_CHOICE_OPTIONS が返ってきた。そんな場面を想定して書いています。単一選択(singleSelect)は、あらかじめ決めた選択肢から1つを選ぶ種類の列です。やっかいなのは、ワークフローの作りによっては、この失敗が起きてもn8nの実行が success のまま終わることです。
この記事の結論
- 原因は、書き込む文字列が、その列の既存の選択肢と一字一句同じでないことです。Airtableの公式ドキュメントによると、一致しないときは
typecastを有効にしない限りINVALID_MULTIPLE_CHOICE_OPTIONSで失敗します。 - 弊社では、OpenAIのモデルを差し替えたら出力の語が変わり、選択肢に無い語を書き込もうとして起きました。エラー文には
Insufficient permissions to create new select optionも付いていましたが、種類は権限のエラーではなくINVALID_MULTIPLE_CHOICE_OPTIONSでした。 - n8nで success のまま落ちるのは、失敗を受け止めて先へ流す作りのときです。Airtableノードの On Error が「Continue」、HTTP Requestノードの「Never Error」がオン、Codeノードで失敗を握りつぶしている、の3つを疑います。
- 合否は「実行が success か」では測れません。保存できた件数と、前回の実行との出力の比較で測ります。
実際に起きたこと
2026年9月6日、弊社で動かしているワークフローのOpenAIのモデルを、GPT-5.6 Luna へ移せるかを検証していました。そのうちの1本、trend-content-injector を Luna にしたところ、単一選択の列 industry に入れる値として、Luna は新しい語「中小企業SNS集客」を返しました。
この値を書き込もうとして、Airtableから次のエラーが返りました。2件とも書き込みに失敗していました。
INVALID_MULTIPLE_CHOICE_OPTIONS / Insufficient permissions to create new select option
それなのに、n8nの実行ステータスは success、ノードも success でした。実行の一覧を眺めても異常は見えません。出力の saved:0 を、過去の実行(毎回 saved:1)と並べて、初めて気づきました。
このときの記録には、モデルを替えたときの合否は「実行が success か」では測れない、前回の実行との出力の比較で測る、と残しました。
422には、別の場面でも当たっています。2026年5月14日、品質チェック(Quality Gate)が誤って不合格にした記録を戻すため、qa_status 列を空にしようとしました。空文字 '' を送ると422になり、null と typecast:true の組み合わせで空にできました。
原因
Airtable側:選択肢は完全一致でしか受け付けない
Airtableの公式ドキュメント(フィールドの型の説明)には、単一選択について次の2文があります。
- 「When creating or updating records, if the choice string does not exactly match an existing option, the request will fail with an
INVALID_MULTIPLE_CHOICE_OPTIONSerror unless thetypecastparameter is enabled.」 - 「If
typecastis enabled, a new choice will be created if one does not exactly match.」
送った文字列が、既存の選択肢の名前とぴったり同じでなければ失敗します。typecast を有効にしたときは、Airtableが新しい選択肢を作ります。複数選択(multipleSelects)の列の説明にも、同じ内容の文があります。
HTTPの422は、Airtableのエラーの説明では「The request data is invalid. This includes most of the base-specific validations.」です。ベース(Airtableのデータベース1つ分)ごとの決まりに合わない、という種類の失敗です。
エラー文に「permissions」があっても、権限の問題とは限らない
弊社の件のエラー文には、Insufficient permissions to create new select option という文が付いていました。言葉どおりに訳すと「新しい選択肢を作る権限が足りない」です。この文だけを見ると、トークンの権限の問題に見えます。
ただ、Airtableのエラーの説明では、権限が足りないときの失敗は 403 Forbidden に分けられています。種類も INVALID_PERMISSIONS などで、別のものです。弊社の件は422で、種類は INVALID_MULTIPLE_CHOICE_OPTIONS でした。
上の公式の説明のとおり、この種類は、送った値が既存の選択肢と完全一致しないときに出ます。新しい選択肢が作られるのは、typecast を有効にしたときです。弊社の件でも、書き込もうとしたのは選択肢に無い語でした。このエラーが出たら、トークンの権限を見直す前に、送った値が既存の選択肢と一字一句同じかを確かめます。
出力の文字列が変わると、書き込みが落ちる
弊社の件は、OpenAIのモデルを Luna にしたことで出力の語が変わり、選択肢に無い語が出た時点で、その先の単一選択への書き込みが落ちたものです。モデルを差し替えたときに起きるもう1つの失敗は、n8nでmax_tokens is not supportedが出る原因と直し方 に書きました。
n8n側:失敗を受け止めて先へ流すと success になる
ここからは n8n 2.19.2(package.json に "version": "2.19.2")の実装です。失敗が実行全体のエラーにならない作りは、次の3つです。
1. Airtableノードの On Error が「Continue」
Airtableノードは、作成(Create)も更新(Update)も、入力の1件ごとに try/catch で書き込んでいます。失敗したときに this.continueOnFail() が真なら、エラーを普通の出力の1件として次のノードへ流します。
if (this.continueOnFail()) {
returnData.push({ json: { message: error.message, error } });
continue;
}
throw error;
continueOnFail() が真になるのは、ノードの設定の On Error が「Continue」か「Continue (using error output)」のときです。「Continue」の画面の説明は「Pass error message as item in regular output」です。次のノードには message と error を持った1件が届き、ノードの処理はそのまま終わります。On Error を何も設定していないときは偽なので、ノードは例外を投げ、実行は失敗になります。
2. HTTP Requestノードの「Never Error」
HTTP Requestノードには「Never Error」という設定があります。説明は「Whether to succeeds also when status code is not 2xx」です。オンにすると requestOptions.simple = false が設定され、422の返事も普通の出力として流れます。
3. Codeノードで失敗を握りつぶす
Codeノードの this.helpers.httpRequest は、中で axios(HTTP通信のライブラリ)を使っています。axios の既定は2xxだけを成功とみなすので、何も指定しなければ422で例外になります。ところが ignoreHttpStatusErrors: true を付けると、n8n本体が axiosRequest.validateStatus = () => true; を設定し、どの返事でも例外になりません。try/catch で受け止めて件数だけ返す作りも、同じく実行は success で終わります。
どの作りでも、失敗はノードの中で片付き、実行全体の結果にはエラーが残りません。n8n本体は、実行の結果にエラーが入っているとき(fullRunData.data.resultData.error !== undefined)にだけ、エラー通知用のワークフロー(Error Workflow)を呼びます。そのため、通知も来ません。
「Map Each Column Manually」の検査も、「Continue」なら出力の1件になる
Airtableノードの列の割り当て方には「Map Automatically」と「Map Each Column Manually」があります。後者で Typecast をオフにしていると、n8nは送る前に値を検査します。検査に使う選択肢は、ノードの設定に保存された列の定義(schema)から取ります。選択肢に無い値なら、次のエラーになります。
Invalid input for 'industry' [item 0]
'industry' expects one of the following values: [...] but we got ...
([...] には選択肢の一覧、... には実際に来た値が入ります。)
ただし、この検査は、1件ごとの書き込みと同じ try ブロックの中で行われます。作成でも更新でも同じです。On Error が「Continue」なら、このエラーも普通の出力の1件として次へ流れ、実行は success のままです。Airtableへ送る前に実行が止まるのは、On Error を何も設定していないか、「Stop Workflow」にしているときです。
Typecast をオンにすると、この検査は飛ばされます(作成の処理では skipValidation: typecast)。「Map Automatically」では入力の項目をそのまま送るので、この検査はありません。どちらの場合も、選択肢に無い値はそのままAirtableへ送られます。
直し方
手順1:書き込みの失敗を、実行の失敗として見えるようにする
まず、書き込みが落ちたら実行も失敗になるようにします。
- Airtableノードは、On Error を「Stop Workflow」にします。画面の説明は「Halt execution and fail workflow」です。止めずに続けたいなら「Continue (using error output)」にします。説明は「Pass item to an extra
erroroutput」で、エラー用の出力が別に出るので、その先に通知をつなぎます。 - HTTP Requestノードは、「Never Error」をオフにします。
- Codeノードは、失敗した件数を数え、1件でもあれば最後に例外を投げます。
Codeノードで書き込む場合の形です(モードは「Run Once for All Items」)。トークンは直接書かず、資格情報か環境変数から読んでください。
const items = $input.all();
let saved = 0;
const failed = [];
for (const item of items) {
try {
await this.helpers.httpRequest({
method: 'PATCH',
url: 'https://api.airtable.com/v0/XXXX/XXXX',
headers: { Authorization: 'Bearer XXXX' },
body: { records: [{ id: item.json.id, fields: { industry: item.json.industry } }] },
json: true,
});
saved++;
} catch (e) {
failed.push(item.json.industry);
}
}
if (failed.length > 0) {
throw new Error(`Airtableへの書き込みが ${failed.length} 件失敗: ${failed.join(', ')}`);
}
return [{ json: { saved } }];
Codeノードの On Error を何も設定していなければ、例外を投げた時点で実行は失敗になり、実行の結果にエラーが入ります。エラー通知用のワークフローは、n8nの公式ドキュメントのとおり Workflow Settings でワークフローごとに設定します。設定していれば、ここで呼ばれます。
ただし、エディタで手動で実行したときは呼ばれません。n8n 2.19.2 では、エラー通知用のワークフローを呼ぶ処理が if (!isManualMode) の中にあります。isManualMode は、実行のモードが manual のときに真です。エディタからの実行はこのモードで動きます。テスト用のWebhookのURLで受けた実行も、同じく manual です。
Codeノードを使わない場合は、件数を見て Stop And Error ノードへつなぐ形でも同じことができます。n8nの公式ドキュメントにも、条件に合ったときに実行をわざと失敗させ、エラー通知用のワークフローを動かす方法として、このノードが載っています。
手順2:書き込む値を、列の既存の選択肢にそろえる
Airtableは完全一致でしか受け付けないので、送る値を既存の選択肢のどれかと一字一句同じにします。
選択肢の一覧は、ベースの構造を返すAPI GET https://api.airtable.com/v0/meta/bases/{baseId}/tables で読めます。トークンには schema.bases:read のスコープが要ります。n8nのAirtableノードも、列の一覧を作るときに meta/bases/${base}/tables を呼び、field.options.choices の name を選択肢にしています。
次は、選択肢を読み、書き込む前に照らし合わせる形の例です。選択肢に無い値が来たら、書き込まずに止めます。
// 「Run Once for All Items」で動かす
const meta = await this.helpers.httpRequest({
method: 'GET',
url: 'https://api.airtable.com/v0/meta/bases/XXXX/tables',
headers: { Authorization: 'Bearer XXXX' },
json: true,
});
const table = meta.tables.find((t) => t.id === 'XXXX');
const field = table.fields.find((f) => f.name === 'industry');
const choices = field.options.choices.map((c) => c.name);
const out = [];
for (const item of $input.all()) {
const value = String(item.json.industry ?? '').trim();
if (!choices.includes(value)) {
throw new Error(`選択肢に無い値です: ${value}`);
}
out.push({ json: { ...item.json, industry: value } });
}
return out;
弊社の件のように、モデルの出力を単一選択へ書く作りについては、弊社の記録に「直すなら既存の選択肢をプロンプトに列挙して選ばせる」と書き残しています。ただ、プロンプトに並べただけでは、書き込む値が選択肢の中にあることまでは保証されません。上の照らし合わせは、その場合にも書き込みの手前で止めるためのものです。
選択肢を増やしてよい列なら、typecast を有効にする方法もあります。公式ドキュメントによると、typecast を有効にすると、完全一致しない値が来るたびに新しい選択肢が作られます。レコードの更新の説明には、typecast について「To ensure your data’s integrity, this should only be used when necessary.」という注意もあります。n8nのAirtableノードでは、この設定は Typecast という項目で、説明は「Whether the Airtable API should attempt mapping of string values for linked records & select options」です。
手順3:選択肢の列を空にするときは ” でなく null を送る
弊社の記録では、単一選択・複数選択の列を PATCH で空にするとき、空文字 '' を渡すと422になる、としています。空にするときは null を送ります。弊社では typecast も付けた次の形で、qa_status 列を空にできました。
{
"records": [
{ "id": "recXXXX", "fields": { "qa_status": null } }
],
"typecast": true
}
弊社で確かめたのは、この形で空にできたことまでです。typecast を付けずに null だけを送った場合は、試していません。
この形を写すときは、選択肢の列へ入れるほかの値を、同じ要求に混ぜないでください。手順2で見たとおり、typecast を有効にすると、選択肢と完全一致しない値は新しい選択肢として作られます。空にする要求は、空にする列だけを送る形に分けます。
送るときは PATCH を使います。Airtableのレコードの更新の説明によると、PATCH は送った項目だけを更新します。PUT は「A PUT request will perform a destructive update and clear all unincluded cell values.」とあり、送らなかった項目まで空になります。
直ったかを確かめる
- 保存できた件数を出力に残し、過去の実行と並べます。弊社が気づけたのも、
saved:0と過去のsaved:1を並べたからです。 - Airtableノードを「Continue」のまま使うなら、出力に
messageとerrorを持った項目が無いかを見ます。 - わざと選択肢に無い値を1件流し、実行が失敗になることを確かめます。本番のデータを汚さないよう、試し用のレコードや試し用のベースに向けて流します。
- エラー通知が届くかまで確かめるときは、エディタの手動実行では確かめられません。手順1のとおり、手動の実行ではエラー通知用のワークフローが呼ばれないからです。有効にしたワークフローの定期実行か、本番用のWebhookのURLで起動した実行で確かめます。本番用のWebhookのURLで受けた実行のモードは
webhookです。手動で試して通知が来なくても、設定が壊れているとは限りません。 - モデルやプロンプトを変えるときは、前回の実行と出力を並べます。2026年9月21日にほかのワークフローのモデルを移したときは、直近の成功した実行の入力でCodeノードを再生し、OpenAIへの要求だけを今のモデルと Luna の両方へ送って並べました。そのあいだ、書き込み(POST/PATCH/PUT/DELETE)は全部止めました。
比べる相手の過去の実行が残っていないと、比べられません。実行履歴が消える仕組みと残し方は、n8nの実行履歴が数日で消える原因と自動削除の設定 に書きました。
それでも直らないとき
- 前後の空白や改行が付いていないか。選択肢は完全一致なので、見た目が同じでも空白1つで失敗します。手順2の例のように
trim()してから比べます。 - 複数選択の列ではないか。複数選択は選択肢の名前の配列で送ります。フィールドの型の説明には「this array-type field will override the current cell value when being updated.」とあり、更新すると今の値が置き換わります。今の値を残したいなら、今の値も配列に入れて送ります。
- Airtableで選択肢を足した直後ではないか。「Map Each Column Manually」の検査は、ノードの設定に保存された列の定義を使います。Airtable側で選択肢を足したら、その選択肢がノードの列の定義にも入っているかを確かめます。
- 422ではなく権限のエラーではないか。トークンがそのベースに触れない場合は、422ではなく
403 Forbiddenの権限のエラーになります。Airtableのエラーの説明に従い、使っている人とトークンの両方がベースに触れるかを確かめます。
まとめ
INVALID_MULTIPLE_CHOICE_OPTIONSは、typecast を有効にしていないとき、書き込んだ文字列が単一選択・複数選択の既存の選択肢と完全一致しないと出ます。エラー文に permissions の語があっても、権限のエラー(403 Forbidden)とは別の種類です。- 弊社では、OpenAIのモデルの差し替えで出力の語が変わり、選択肢に無い語を書き込もうとして2件とも失敗しました。実行は success のままでした。
- n8nでは On Error の「Continue」、HTTP Requestの「Never Error」、Codeノードの try/catch で、失敗が success に化けます。「Map Each Column Manually」の検査も、「Continue」なら出力の1件になります。
- 直し方は、失敗を実行の失敗として見えるようにすることと、送る値を既存の選択肢にそろえることです。選択肢の列を空にするときは
nullを送ります。 - 確かめるのは、保存できた件数と前回の実行との比較です。エラー通知は、手動の実行では呼ばれないので、本番の起動で確かめます。
参考にした公式ドキュメント






