はじめに
先に結論を書きます。e-Stat API で自分のドメインのデータを探すなら、統計表を検索する前に「294 の政府統計の在庫表」をローカルに 1 回だけ作ってしまうのが最短です。 作ってしまえば、あとは手元の CSV を grep するだけで「自分の業界はどの統計を見ればいいか」が数秒で決まります。
対象読者
- 商圏分析・市場規模推計などで政府統計を業務に取り込みたい人
- e-Stat を開いてはみたものの、目的のデータに辿り着けず閉じた経験がある人
- 「政府統計 = 国勢調査」くらいの認識で止まっている人(かつての自分です)
e-Stat を開いて、検索窓に業種名を入れて、出てきた数百件を眺めたまま閉じた経験はありませんか。自分はあります。
小売の商圏分析で母集団の数字が要るようになり、e-Stat を触り始めました。で、最初に何をしたかというと、画面の検索窓に業務のキーワードを入れて片っ端から叩いたんです。結果、似た名前の統計表が何百と並び、どれが目的のものか判断できないまま 1 時間が溶けました。問題は検索のやり方ではなく、そもそも全体の在庫が見えていなかったことでした。
参考文献
先に一次情報を置いておきます。この記事の内容はすべて下記の仕様書と、自分で API を叩いて取った実測値がもとになっています。
検証環境
- 実施日: 2026-09-06(数値はこの日の実測です)
- API: e-Stat API 仕様 3.0 版、エンドポイントは
https://api.e-stat.go.jp/rest/3.0/app/json/<機能名> - 実行: Python 3.13 + requests
- 事前準備: e-Stat のユーザ登録と appId の発行(マイページから発行、利用予定 URL の登録が要ります)
e-Stat の在庫は 236,645 表ある。しかも偏っている
まず全体像です。絞り込みなしで getStatsList を叩くと、該当件数が返ってきます。
import os
import requests
BASE = "https://api.e-stat.go.jp/rest/3.0/app/json/getStatsList"
params = {"appId": os.environ["ESTAT_APP_ID"], "limit": 1}
res = requests.get(BASE, params=params, timeout=30).json()
print(res["GET_STATS_LIST"]["DATALIST_INF"]["NUMBER"])
# => 236645
NUMBER は「絞り込み後の全件数」なので、limit=1 にして 1 行だけ返させても総数が分かります。なお件数のみを取る cntGetFlg は getStatsData のパラメータで、getStatsList には存在しません。付けても無視されます(レスポンスの PARAMETER に反映されないので確認できます)。
ひとつ前提を置いておくと、この 236,645 表は searchKind の既定値である 1(通常の統計情報)での件数です。小地域・地域メッシュは別枠で、後述する searchKind=2 を指定しないと数にも検索結果にも出てきません。
問題はこの 236,645 表の分布です。294 の政府統計すべてについて統計表数を数えて分野別に集計したら、想像と全然違う形をしていました。
農林水産業だけで全体の 3 分の 1 です。上位 3 分野で 60.4% を占めます。府省別で見ると農林水産省が単独で 34.9%(82,539 表)、総務省が 23.6%(55,824 表)で、2 省だけで 58.5% になります。
そして国勢調査。あれだけ有名なのに 6,807 表で、統計表数では 11 位でした。トップは農業経営統計調査の 25,303 表です。
つまり、e-Stat を「国勢調査があるところ」という感覚で使い始めると、在庫の大半を最初から視界に入れていないことになります。逆に、運輸・観光(906 表)や行財政(593 表)のように極端に薄い分野もあるので、自分のドメインがどれだけ「厚い」か「薄い」かを先に知っておくと、期待値の置き方が変わります。観光まわりのデータを探していて見つからないのは、探し方が悪いのではなく単に薄いだけ、ということが普通に起きます。
自分のドメインの当たりを付ける3ステップ
ここからが本題です。やることは 3 つだけ。
ステップ1: 294統計の在庫表をローカルに作る
getStatsList に statsNameList=Y を付けると、統計表ではなく政府統計そのものの一覧が返ります。これが 294 件。ここが起点です。
注意点として、statsNameList=Y のときレスポンスの要素名は TABLE_INF ではなく LIST_INF になります。通常の統計表検索と要素名が違うので、パーサを使い回すと落ちます。
import csv
import os
import time
import requests
BASE = "https://api.e-stat.go.jp/rest/3.0/app/json/getStatsList"
APP_ID = os.environ["ESTAT_APP_ID"]
def get(params):
res = requests.get(BASE, params={"appId": APP_ID, **params}, timeout=30).json()
root = res["GET_STATS_LIST"]
status = root["RESULT"]["STATUS"]
# 0=正常、1=該当データなし(これも正常終了)、100番台以降がエラー
if status >= 100:
raise RuntimeError(f"e-Stat error {status}: {root['RESULT']['ERROR_MSG']}")
return root
def as_list(x):
"""項目が1個のときオブジェクトで返るので、常にリストに正規化する"""
if x is None:
return []
return x if isinstance(x, list) else [x]
# 1. 政府統計の一覧(294件)を取る
stats_list = get({"statsNameList": "Y"})["DATALIST_INF"]["LIST_INF"]
rows = []
for item in stats_list:
code = item["@id"]
# 2. 各統計の統計表数と分野を、1件だけ引いて拾う
info = get({"statsCode": code, "limit": 1})["DATALIST_INF"]
tables = as_list(info.get("TABLE_INF"))
if not tables:
continue
first = tables[0]
rows.append({
"statsCode": code,
"statsName": item["STAT_NAME"]["$"],
"govOrg": item["GOV_ORG"]["$"],
"category": first["MAIN_CATEGORY"]["$"],
"tables": info["NUMBER"],
})
time.sleep(0.3)
with open("estat_catalog.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
一覧取得 1 回+各統計に 1 回ずつで合計 295 リクエストになるので、数分かかります。一度作れば当分使い回せるので、雑に time.sleep を挟んで放置でいいです。
分野(MAIN_CATEGORY)は政府統計ではなく統計表に付いている値なので、limit=1 で引いた先頭 1 表の分野を、その統計全体の分野として扱っています。ここは割り切りです。複数分野にまたがる統計では、その統計の全表が先頭表の分野に寄ってしまいます。先ほどの分野別の集計も、この方式で出した概算だと思ってください。 分野別の数を厳密に出すなら、統計表単位でページングしながら集計する必要があります。当たりを付ける用途では、この概算で十分でした。
ここで検算をしておくと安心です。294 件の統計表数を合計すると 236,645 になり、絞り込みなしで getStatsList を叩いたときの NUMBER と一致しました。取りこぼしはありません。
ステップ2: 在庫表をgrepしてstatsCodeを特定する
あとはただのローカル CSV です。業務のキーワードで引きます。
grep -E "小売|商業|消費" estat_catalog.csv
00550200,商業動態統計調査,経済産業省,商業・サービス業,1466
00200552,家計調査,総務省,企業・家計・経済,126
00200564,全国家計構造調査(旧全国消費実態調査),総務省,企業・家計・経済,5164
画面で検索していたときは「似た名前の統計表が数百件」だったものが、統計のレベルで見れば数件に絞れるんです。粒度がひとつ上がるだけで探索の難易度が変わります。ここに気付くのに自分は遠回りをしました。
ステップ3: searchWordで統計表IDまで降りる
統計が決まったら、その中を searchWord で検索します。searchWord は AND / OR / NOT を書けます。
tables = as_list(get({
"statsCode": "00200552", # 家計調査
"searchWord": "品目分類 AND 二人以上の世帯",
})["DATALIST_INF"].get("TABLE_INF"))
for t in tables[:5]:
print(t["@id"], t["SURVEY_DATE"], t["TITLE"]["$"][:40])
返ってくる @id が統計表 ID で、これが数値取得の起点になります。OVERALL_TOTAL_NUMBER にその表の総データ件数が入っているので、取得計画を立てるときはここを先に見ます。
ドメイン別「まずここを見る」対応表
在庫表を作ったあと、自分の業務まわりで当たりを付けた結果を置いておきます。統計表数も添えました。数が多い=使いやすい、では全くないので、そこは誤解しないでください。
| ドメイン | まず見る政府統計 | 統計表数 | 何が分かるか |
|---|---|---|---|
| 小売・商圏 | 経済センサス‐活動調査 | 1,094 | 市区町村×産業別の事業所数・従業者数 |
| 小売・商圏 | 商業動態統計調査 | 1,466 | 小売業販売額の月次動向 |
| 消費・マーケ | 全国家計構造調査 | 5,164 | 世帯属性×支出の詳細 |
| 消費・マーケ | 家計調査 | 126 | 品目別支出の時系列 |
| 人口・エリア | 国勢調査 | 6,807 | 小地域・メッシュの人口世帯 |
| 人口・エリア | 住民基本台帳人口移動報告 | 471 | 人口の流入・流出 |
| 医療・健康 | 医療施設調査 | 3,177 | 施設数・診療科の分布 |
| 医療・健康 | 国民健康・栄養調査 | 482 | 栄養摂取・生活習慣 |
| 不動産 | 住宅・土地統計調査 | 11,256 | 住宅ストック・空き家 |
| 不動産 | 建築着工統計調査 | 167 | 着工の動向 |
| 観光・インバウンド | 訪日外国人消費動向調査 | 517 | 国籍別の消費単価・費目 |
| 観光・インバウンド | 宿泊旅行統計調査 | 32 | 延べ宿泊者数 |
| 教育 | 学校基本調査 | 7,801 | 学校数・在学者数 |
| 製造 | 経済産業省生産動態統計調査 | 12,191 | 品目別の生産・出荷・在庫 |
| 通信・IT | 通信利用動向調査 | 5,256 | 世帯・企業の ICT 利用状況 |
宿泊旅行統計調査が 32 表しかないのを見ると、最初は「これしか無いの?」と思うはずです。自分も思いました。でもこれは在庫が薄い分野の正常な姿で、逆にその 32 表を全部眺めるコストが低いということでもあります。
数値を取るまでの流れ
統計表 ID が決まれば、数値取得は 1 リクエストで済みます。
import os
import requests
APP_ID = os.environ["ESTAT_APP_ID"]
DATA = "https://api.e-stat.go.jp/rest/3.0/app/json/getStatsData"
res = requests.get(DATA, params={
"appId": APP_ID,
"statsDataId": "0003348239",
"cdArea": "00000", # 全国
"cdTime": "2024000000", # 2024年
}, timeout=30).json()
root = res["GET_STATS_DATA"]
status = root["RESULT"]["STATUS"]
if status >= 100:
raise RuntimeError(root["RESULT"]["ERROR_MSG"])
if status == 1: # 正常終了だが該当データ0件
raise SystemExit("該当データがありません。絞り込み条件を見直してください")
stat = root["STATISTICAL_DATA"]
class_obj = as_list(stat["CLASS_INF"]["CLASS_OBJ"]) # コード→名称の対応表
values = as_list(stat["DATA_INF"]["VALUE"]) # 数値本体
1 リクエストの取得上限は 10 万件です。それを超えるとレスポンスの RESULT_INF.NEXT_KEY が返ってくるので、それを次のリクエストの startPosition に渡してページングします。NEXT_KEY が無ければ最終ページです。
getMetaInfo を別途叩かなくても済むのは、getStatsData の metaGetFlg の既定値が「取得する」だからです。同じメタ情報がレスポンスに同梱されて返ってきます。メタを別管理していて本当に数値だけ欲しいときは metaGetFlg=N を明示すると、レスポンスが軽くなります。
ここで大事なのが、VALUE は単体では何の数値なのか一切分からないという点です。中身はこうなっています。
{"@tab": "01", "@cat01": "1", "@area": "00000", "@time": "2024000000", "@unit": "円", "$": "3602915"}
@cat01 が 1 である、としか書いていない。この 1 が何なのかは CLASS_OBJ の対応表と突き合わせて初めて「消費支出」だと分かります。ファクトとディメンションが分かれたスタースキーマだと思うと腑に落ちました。
実際に家計調査(統計表 0003348239、二人以上の世帯・品目分類 2020 年改定)から全国・2024 年を取ると、こうなりました。
| 品目 | 年間支出(円) |
|---|---|
| 消費支出 | 3,602,915 |
| 1 食料 | 1,079,228 |
| 10 その他の消費支出 | 567,737 |
| 7 交通・通信 | 500,766 |
| 9 教養娯楽 | 362,877 |
| 3 光熱・水道 | 277,332 |
| 2 住居 | 217,054 |
| 6 保健医療 | 184,178 |
| 4 家具・家事用品 | 153,459 |
| 8 教育 | 140,462 |
| 5 被服及び履物 | 119,822 |
時間軸が年次なので値は年計です。消費支出を 12 で割ると 300,243 円/月になり、公表されている月平均と整合しました。取得した数値が公表値と合うかを 1 回確認しておくと、以降の集計の安心感が全然違います。
ハマったポイント
ここからが本音のパートです。
仕様書にはHTTP 403と書いてあるのに、実際は200が返ってくる
API 仕様 3.0 版の処理結果コード表には、認証エラー(STATUS 100)は HTTP 403、データ非存在(300)は HTTP 400 と書かれています。それを読んで「じゃあ raise_for_status() で弾けるな」と思ったのですが、実際に JSON エンドポイントを叩いたらこうなりました。
curl -s -o /dev/null -w "HTTP=%{http_code}\n" \
"https://api.e-stat.go.jp/rest/3.0/app/json/getStatsList?appId=INVALID_TEST_ID&limit=1"
HTTP=200
本文はこれです。
{"GET_STATS_LIST":{"RESULT":{"STATUS":100,"ERROR_MSG":"認証に失敗しました。アプリケーションIDを確認して下さい。","DATE":"2026-09-12T15:03:38.717+09:00"}}}
HTTP は 200、中身は認証失敗。 raise_for_status() を書いて安心していた自分のコードは、appId が間違っているのを素通りして、空の配列を処理して静かに終わっていました(2026-09-12 に再現を確認しています。実装が変わる可能性はあるので、HTTP ステータスに依存しない書き方をしておくのが安全です)。
成否判定は必ずレスポンス内の RESULT.STATUS で行います。そしてここにもうひとつ罠があって、正常終了は 0 だけではありません。
| STATUS | 意味 |
|---|---|
| 0 | 正常終了 |
| 1 | 正常に終了したが、該当データが 0 件 |
| 2 | 統計データ一括取得時のメッセージ |
| 100 | 認証に失敗(appId が不正) |
| 300 | 指定した ID のデータが存在しない |
最初に書いた if status != 0: raise は、条件を絞りすぎて 0 件になっただけのリクエストまで例外にしていました。絞り込みを試行錯誤している最中にこれをやると、原因がエラーなのか 0 件なのか区別が付かなくなります。100 以上をエラー、1 は正常な 0 件として扱うのが実態に合っています。
RESULT.STATUS を見ないコードは、動いているように見えて空データを流し続けます。バッチに組み込むなら最初に入れてください。
項目が1個のときだけ配列でなくオブジェクトで返る
JSON 変換の都合で、同名要素が複数あれば配列、1 個しかなければオブジェクトで返ってきます。CLASS_OBJ 配下の CLASS(分類項目)でも、検索結果の TABLE_INF でも同じことが起きます。
つまり、分類軸をループで回すコードは、たまたま項目が 1 個の統計表を踏んだ瞬間に TypeError で落ちます。検索結果を tables[:5] でスライスするコードも、ヒットが 1 件だった瞬間に落ちます。ローカルで試したときは複数件ヒットしていたので気付かず、条件を絞り込んだ本番で踏みました。
前に載せたスクリプトで as_list() を噛ませているのはこのためです。
def as_list(x):
if x is None:
return []
return x if isinstance(x, list) else [x]
秘匿値の X を欠測と同じ扱いにすると集計が狂う
これが一番怖かったやつです。数値が入らないセルには記号が入るのですが、その凡例が統計表ごとに違います。
国勢調査では 2 記号(*** 調査又は集計していないもの、- 該当数字がないもの)でした。ところが家計調査では 3 記号あり、X(数値が秘匿されているもの)が増えます。
秘匿は「値が存在しないこと」ではなく「値はあるが公開されないこと」です。これを欠測と同一視して 0 埋めや除外をすると、集計結果が静かにズレます。凡例はハードコードせず、レスポンスの NOTE を毎回読むのが正解でした。
小地域とメッシュは検索しても出てこない
町丁・字レベルや 500m・1km メッシュのデータは、通常の getStatsList の結果には出てきません。searchKind=2 を明示的に指定して初めて検索対象になります。searchKind の既定値は 1(統計情報)です。
国勢調査でいうと、通常検索では 6,807 表ですが、searchKind=2 を付けると小地域だけで 5,306 表が別に存在します。商圏分析をやるなら、むしろこちら側が本命です。なお searchKind=2 のときは collectArea(集計地域区分)の指定が無効になります。
分類コードは統計・年次ごとに変わる
cat01 の 1 が「消費支出」なのは、その統計表においてだけです。別の統計表や別年次では同じコードが別の意味になります。コードをスクリプトに直書きすると、翌年の更新で黙って壊れます。毎回 getMetaInfo から引くのが結局いちばん楽でした。
取れないもの
期待して時間を使う前に知っておきたかったことです。
-
PDF/Excel でしか提供されていない統計表の中身。API の対象は e-Stat 上で「データベース」形式になっている統計表だけです。ファイル形式のものは
getDataCatalogで所在(URL)までは分かりますが、数値のパースは自前になります。 - 境界データ(ポリゴン)そのもの。API が返すのは地域コード付きの数値であって図形ではありません。地図に落とすには統計 GIS の境界データを別途ダウンロードし、地域コードで結合します。
地図をいきなり描こうとして「API から境界が返ってこない」と悩んだのですが、これは仕様どおりでした。
まとめ
- 通常検索(
searchKind=1)で引ける統計表は 236,645 表、政府統計は 294 件(2026-09-06 実測) - 在庫の偏りが大きく、農林水産業だけで 34.4%。国勢調査は 6,807 表で 11 位
- 統計表を検索する前に、294 統計の在庫表をローカル CSV にすると探索が grep になる
-
statsNameList=Yのときの要素名はLIST_INF。通常のTABLE_INFとは違う - 成否は
RESULT.STATUSで判定する。仕様書の記載と違い、認証エラーでも HTTP 200 が返ってきた -
STATUSは0だけが正常ではない。1は「該当データ 0 件」の正常終了 - 秘匿値
Xは欠測ではない。凡例は統計表ごとにNOTEから読む
在庫表さえ手元にあれば、次に別ドメインの案件が来ても「まずどの統計か」で迷わなくなります。自分は商圏分析のつもりで作りましたが、結局いちばん効いたのは探す対象を統計表ではなく政府統計に一段上げたことでした。
同じように e-Stat で迷った人がいたら、どの分野を掘っているか教えてください。分野ごとの在庫の厚みは本当にバラバラなので、薄い分野の攻め方は自分もまだ手探りです。

