郵便番号APIが返す複数候補をどう扱うか
同じ郵便番号に複数の町域が対応する理由と、「以下に掲載がない場合」のような特殊表記の扱い方を整理します。候補の正規化・重複排除・件数上限まで、フォーム以外の場面も含めて説明します。
執筆:住所APIナビ編集部
郵便番号APIのレスポンスが配列で返ることは、ドキュメントを読めば分かります。問題は、配列が2件以上になったときに何をすればいいのかが、どこにも書かれていないことです。
「ユーザーに選ばせる」だけでは足りません。選択肢そのものが、そのまま住所欄に入れてはいけない文字列であることがあるからです。
なぜ複数になるのか
日本郵便が公開している郵便番号データの読み仕様には、次の項目があります。
「一つの郵便番号で二以上の町域を表す場合の表示」とは、一つの郵便番号で複数の町域をまとめて表しており、郵便番号と番地だけでは住所が特定できないことを示すものです。
重要なのは後半です。郵便番号と番地だけでは住所が特定できないと、データの提供元自身が明記しています。CSVには「複数町域表示」という専用の列が用意されており、これは例外的な事故ではなく、仕様として想定されている状態です。
zipcloud のドキュメントも「同一の郵便番号で複数件のデータが存在する場合は、以下の項目が配列として返される」と説明しています。
町域名が「町域名でない」ことがある
複数候補より先に潰すべきなのがこちらです。日本郵便の読み仕様には、町域名の欄に入る特殊な表記が3種類説明されています。
| 表記 | 読み仕様での説明 |
|---|---|
| 「以下に掲載がない場合」 | お探しの町域が見つからない場合にお書きいただく番号 |
| 「○○市(または町・村)の次に番地がくる場合」 | 市区町村名の後ろに町域名がなく、番地がくる住所の場合 |
| 「○○市(または町・村)一円」 | 町域名がない市区町村の場合 |
いずれも町域名の位置に入ります。 そのまま連結すると 北海道札幌市中央区以下に掲載がない場合 のような文字列ができあがり、それが配送先として保存されます。ユーザーは自分で入力していないので、間違いに気づきません。
データ源によって、すでに処理されている場合がある
ここは実際に確かめる必要があります。zipcloud のドキュメントは、データを「加工済バージョン」と説明しています。実際に 060-0000 を検索したところ、address3 は特殊表記ではなく空文字列で返りました(2026-07-29 に確認)。
GET https://zipcloud.ibsnet.co.jp/api/search?zipcode=0600000
→ address1: 北海道 / address2: 札幌市中央区 / address3: ""(空文字列)つまり、扱うべき値がどちらの形で来るかは、データ源しだいです。
- 郵便番号データのCSVを自前で読み込んでいる → 特殊表記がそのまま入っている
- 加工済みのAPIを使っている → 空文字列になっていることがある
- どちらなのかドキュメントに明記されていない → 自分で1件叩いて確かめる
判定して落とす
CSVを直接扱う場合や、加工の有無が確認できないデータ源を使う場合は、自分で判定します。
// src/lib/postal-code/town.ts
/**
* 日本郵便の郵便番号データで、町域名の位置に入る特殊表記。
* 住所として連結してはいけない。
*/
const NON_TOWN_PATTERNS: readonly RegExp[] = [
/^以下に掲載がない場合$/,
/^.+?[市町村]の次に番地がくる場合$/,
/^.+?[市町村]一円$/,
]
/** 町域名として住所に連結してよい文字列かどうか。 */
export function isUsableTownName(town: string): boolean {
const trimmed = town.trim()
if (trimmed === '') return false
return !NON_TOWN_PATTERNS.some((pattern) => pattern.test(trimmed))
}町域名として使えないと判定したら、その候補を捨てるのではなく、町域を空にして残します。 都道府県と市区町村は正しいので、そこまでは自動入力し、続きをユーザーに入力してもらうのが正解です。
// src/lib/postal-code/candidate.ts
import { isUsableTownName } from './town'
export type Candidate = {
prefecture: string
city: string
/** 特殊表記だった場合は空文字。住所欄に連結してはいけない。 */
town: string
}
type ApiResult = {
address1: string
address2: string
address3: string
}
export function toCandidate(result: ApiResult): Candidate {
const town = result.address3.trim()
return {
prefecture: result.address1.trim(),
city: result.address2.trim(),
town: isUsableTownName(town) ? town : '',
}
}候補を整理する
特殊表記を落とすと、都道府県・市区町村まで同じ候補が複数残ることがあります。ユーザーに同じ選択肢を3つ見せても、選べません。
// src/lib/postal-code/candidate.ts(続き)
function candidateKey(candidate: Candidate): string {
return `${candidate.prefecture}�${candidate.city}�${candidate.town}`
}
/** 表示順を保ったまま、完全に同じ候補を1件にまとめる。 */
export function dedupeCandidates(candidates: Candidate[]): Candidate[] {
const seen = new Set<string>()
const result: Candidate[] = []
for (const candidate of candidates) {
const key = candidateKey(candidate)
if (seen.has(key)) continue
seen.add(key)
result.push(candidate)
}
return result
}区切り文字に � を使っているのは、町域名に含まれうる文字(- や空白)でキーが衝突しないようにするためです。
並べ替えはしないでください。 APIが返した順序には意味がある可能性があり、こちらで五十音順などに並べ替えると、提供元の意図した順序が失われます。
件数で分岐する
ここまで整理してから、件数で処理を分けます。
| 件数 | やること |
|---|---|
| 0件 | エラーにしない。「見つかりませんでした」と伝え、手入力で先へ進ませる |
| 1件(町域あり) | 自動入力してよい |
| 1件(町域が空) | 都道府県・市区町村まで入れ、町域欄はユーザーに入力してもらう |
| 2件以上 | 選択UIを出す。1件目を既定で選んだ状態にしない |
| 上限ちょうど | 打ち切られている可能性を疑い、limit の指定を見直す |
// src/lib/postal-code/decide.ts
import type { Candidate } from './candidate'
export type LookupOutcome =
| { kind: 'empty' }
| { kind: 'autofill'; candidate: Candidate }
| { kind: 'choose'; candidates: Candidate[] }
export function decideOutcome(candidates: Candidate[]): LookupOutcome {
if (candidates.length === 0) return { kind: 'empty' }
const [first] = candidates
if (candidates.length === 1 && first) {
// 町域が空でも自動入力する。都道府県・市区町村までは確定しているため。
return { kind: 'autofill', candidate: first }
}
return { kind: 'choose', candidates }
}選択UIで守ること
選択肢は <fieldset> + ラジオボタンにします。ボタンの並びでも動きますが、ラジオなら「1つ選ぶ」ことがマークアップから伝わり、キーボードの矢印キーで移動できます。
// 候補の選択部分のみ抜粋
type Props = {
candidates: { prefecture: string; city: string; town: string }[]
onSelect: (index: number) => void
}
export function CandidateChoice({ candidates, onSelect }: Props) {
return (
<fieldset>
<legend>住所を選んでください({candidates.length}件)</legend>
{candidates.map((candidate, index) => {
const label =
`${candidate.prefecture}${candidate.city}${candidate.town}` ||
`${candidate.prefecture}${candidate.city}`
return (
<label key={label} htmlFor={`candidate-${index}`}>
<input
type="radio"
id={`candidate-${index}`}
name="address-candidate"
value={index}
onChange={() => onSelect(index)}
/>
{label}
</label>
)
})}
</fieldset>
)
}件数を <legend> に出しておくと、スクリーンリーダーでも「いくつあるのか」が分かります。候補が出たことは、色や配置ではなく role="status" のテキストでも伝えてください。
保存するときの形
複数候補の扱いで最後に効いてくるのが、何を保存するかです。
// 避けたい形:連結済みの1本の文字列だけを持つ
type BadRecord = { address: string }
// 望ましい形:要素を分けて持ち、由来も残す
type AddressRecord = {
postalCode: string
prefecture: string
city: string
town: string
/** 番地・建物名。APIは返さないので、必ずユーザー入力。 */
addressLine: string
/** 自動入力か手入力かを残しておく。 */
source: 'postal-code-api' | 'manual'
}連結した文字列だけを保存すると、あとから「町域までがAPI由来で、そこから先が手入力」という境界が分からなくなります。特殊表記の混入に気づいて修正したくなったとき、対象レコードを特定できません。 要素を分けて持っておけば、町域だけを条件に検索できます。
フォーム以外の場面
既存データの一括処理では、選択UIという逃げ道がありません。ここで判断を誤ると、間違いが静かに広がります。
- 複数候補を機械的に1件目で確定しない。 上と同じ理由です。件数が2件以上のレコードは「要確認」として印を付け、処理から外してください。
- 件数と特殊表記の有無を集計する。 全体の何%が自動確定できて、何%が人手判断になるのかを先に出します。この数字を見ずに始めると、途中で作業量が読めなくなります。
- APIを1件ずつ叩く前に、レート制限を確認する。 zipcloud と日本郵便APIは、どちらも制限がある旨だけを記載し、具体的な数値は公開していません。一括処理は、規約上の制限にも最も抵触しやすい使い方です。
動作環境
掲載しているコードは Node.js 24.x / TypeScript 5.9(strict)/ React 19 を前提に書いています。構文チェックは自動で行っていますが、断片であるため完全な型チェックは通していません。ご自身の環境で確認してください。
関連ページ
- Next.jsで郵便番号から住所を自動入力する方法 — フォーム全体の実装手順
- 日本郵便APIとzipcloudの違い — どちらのAPIを使うか
- 全角・半角・ハイフンを整える住所正規化の基本
- 住所整形ツール — 形式の正規化をブラウザ内で試す