ブログ

【AWS】Athenaの「難解なエラー」にどう立ち向かうか ― 原因が見えない2つのエラーと、その解決方法

この記事をSNSでシェア!

はじめに

Amazon Athenaは、S3に配置したデータに対してサーバーレスで直接SQLクエリを実行できる、非常に手軽で強力なサービスです。サーバーやクラスターの管理が不要で、CSVやJSON、Parquetといったフォーマットをそのまま読み込める利便性は、データ分析のハードルを大きく下げてくれます。

しかし、この快適な運用が足止めを食らうのが、データ起因のエラーに遭遇したときです。

本記事では、現場で繰り返し遭遇する「原因の全体像が見えない2つの難解なエラー」に焦点を当て、メッセージの表面的な症状に振り回されずに、機械的かつ一括で原因を特定・解決するための具体的なアプローチを解説します。

本記事で解決する2つのエラー

  1. HIVE_CURSOR_ERROR: Failed to read file at … ― 破損したJSON行の特定
  2. HIVE_BAD_DATA ― Parquetへの異なるデータ型の混入

これらのエラーに共通する最大の難点は、「最初に見つかった1件の問題でクエリが中断し、データ全体の状況を把握できない」点にあります。メッセージが示す情報はまちまちで、ファイル名止まりのこともあれば、カラム名まで名指しされることもあります。しかし、どれだけ広範囲に不整合が散らばっていても、返ってくるのは常に「最初の1件」だけです。数千ファイル・数百万行を相手に、エラーを1件ずつ修正しては再実行するアプローチは現実的ではありません。1つ直しては再実行し、また次のエラーで止まる――そんな終わりの見えない消耗戦に陥りがちです。

一見すると、これらはエラーメッセージの表現(課題の現れ方)も、テキストとバイナリというファイル形式も全く異なります。しかし、直面している課題の表現が違っても解決すべき根本は同じであり、ファイル形式が違っても突破するためのアプローチの考え方は共通しています。それは、「エラーという表面的な症状にその都度パッチを当てる対症療法をやめ、データ構造の歪みを『一度に、すべて可視化できる場所』から見直す」という視点の転換です。

この共通の思想を、データの性質(形式)に合わせて異なる道具で実装します。テキスト形式であるJSONは、Athenaの内部だけでSQLの小技を使って全行を検査できます。一方、バイナリ形式でファイル自体にスキーマ情報を内包するParquetは、SQLだけでは限界があるため、Athenaの外からPythonスクリプトを用いてファイル単位で調べます。「同じ思想を、データの性質に応じて違う道具で実装する」という道具の使い分けに着目すると、それぞれの解決策の構造がよりクリアに見えてきます。本記事では、これらを検証データの準備から解決の確認まで、一連の流れを通して丁寧に解説します。

対象読者

  • Athenaを利用し、S3上のJSONやParquetを外部テーブルとしてクエリしている方
  • SELECT … WHERE などの基本操作は問題ないが、エラー発生時の原因特定に毎回時間を取られている
  • 「システムの変更はしていないはずなのに、今月から急にクエリが落ちるようになった」という経験がある方

本記事で扱わないこと

本記事の主眼は、Athenaの分かりにくいエラーに対して、原因を素早く特定する手順を身につけることです。そのため、以下のトピックについては扱いません。

  • Athenaの料金体系、パーティション設計、CTASなどの基礎知識
  • SQLおよびTrino関数の入門的な解説
  • パフォーマンスチューニング全般
  • 各SerDe(Serializer/Deserializer)の全オプションの網羅的な解説
  • カラム名の大文字小文字に起因する問題(COLUMN_NOT_FOUND や値がNULLで返る事象など)――こちらも頻出ですが、紙幅の都合で別記事に譲ります

前提

読み進めるうえで、以下の環境や構成を前提として解説を進めます(特別な準備は不要です)。

  • 対象データがS3に配置され、AWS Glue Data Catalogに外部テーブルとして登録されている
  • Athenaのクエリエンジンは Engine v3 を利用している
  • JSONはJSON Lines形式(1行1レコード)、ParquetはSnappy圧縮といった一般的な構成

なお、本記事で紹介する挙動は 2026年6月時点・Athena Engine v3 で確認したものです。SerDeのプロパティや型変換の規則は、今後のエンジンアップデートによって変更される可能性があるため、実際の環境でも挙動をご確認ください。

エラー① 壊れたJSON行 ― HIVE_CURSOR_ERROR: Failed to read file at …

今回の検証データ

このセクションでは、次の4行で構成されたJSON Linesファイルを使用します。3行目だけ、行末の閉じ括弧( } )が欠落しているのがポイントです(図1)。

cat <<'EOF' > events.jsonl
{"user_id": "u001", "amount": 120}
{"user_id": "u002", "amount": 340}
{"user_id": "u003", "amount": 560
{"user_id": "u004", "amount": 780}
EOF
aws s3 cp events.jsonl s3://your-bucket/events_json/
図1:検証データ。3行目だけ行末の閉じ括弧 } が欠けている

このファイルをS3にアップロードし、user_id と amount の2つのカラムを持つ外部テーブルとして登録した状態から検証を開始します。

どんなエラーか

このテーブルに対して通常のクエリを実行すると、処理は中断され、以下のようなエラーが返されます。実際のAthenaコンソールでは図2のように表示されます。

HIVE_CURSOR_ERROR: Failed to read file at
s3://your-bucket/events_json/events.jsonl
図2:壊れたJSONを含むテーブルへのクエリがエラーで落ちた画面

エラーメッセージを確認すると、問題が発生したファイルパスまでは特定してくれています。対象ファイルが1つだけであれば、これだけでも対応可能かもしれません。しかし、大規模なデータ運用においては次の2つの課題が残ります。

  • 具体的にどの行が原因なのかが分からない。 1つのファイルに数万行以上のデータが含まれている場合、目視で破損箇所を探すのは不可能です。
  • 一度に1つのファイルしか特定できない。 クエリは最初に不整合を検知したファイルで停止するため、破損ファイルが10個あれば「修正 → 再実行 → 次のエラー」を10回繰り返すことになります。数千ファイル規模の環境では、終わりが見えない消耗戦に陥ります。

なぜ起きるのか

JSON SerDeは、データを「1行=1つのJSONオブジェクト」として厳密にパースします。そのため、途中に「閉じ括弧の不足」「不要なカンマ」「JSONフォーマットを満たさない文字列」が1行でも混入すると、パース処理で例外が発生し、その時点でクエリ全体の実行が停止します。Athenaが通知してくれるのは「最初にエラーを踏んだファイル」までであり、該当する行番号や、他に同様のファイルが存在するかどうかまでは教えてくれません。

応急処置 ― まずクエリを通したいなら

根本的な原因を特定する前に、「まずは暫定的に集計処理を通したい」という場合の応急処置が存在します。OpenX JSON SerDeの ignore.malformed.json プロパティを利用する方法です。

CREATE EXTERNAL TABLE events_json_malformed (...)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
WITH SERDEPROPERTIES ('ignore.malformed.json' = 'true')
LOCATION 's3://your-bucket/events_json/';

このプロパティを true に設定すると、パースに失敗した行で例外を投げる代わりに、その行の全カラムをNULLとして読み飛ばし、クエリを最後まで完走させることができます(図3)。

図3:同じデータでもクエリが完走し、壊れた行は全カラムNULLの行として処理される

ただし、これは「破損した行を一時的に無視している」に過ぎず、どの行が、なぜ破損しているのかという根本原因は解明されていません。意図しないデータ欠損を見落とすリスクがあるため、この応急処置で時間を稼ぎつつ、以下の手順で根本原因を特定することをおすすめします。

どう原因を特定するか

目指すゴールは、エラーメッセージからは読み取れない「破損している具体的な行」「破損しているファイルの全量」を、1回のクエリで同時に洗い出すことです。

ここで必要となるのは、エラーメッセージに頼るのではなく、データの読み込み方自体を転換することです。ポイントは、「JSONを、あえてCSVテーブルとして1列で読み込む」という手法です。

JSON SerDeを介するとパースエラーでクエリが落ちてしまいますが、区切り文字に「データ内に絶対に登場しない文字」を指定したCSVテーブルとして定義すれば、パース処理を回避できます。これにより、1行のデータ丸ごとを1つの string カラムとして安全に読み込めるようになります。読み込みさえ成功すれば、あとはSQL関数を用いて「JSONとしてパースできない行」を炙り出すだけです。

区切り文字には、制御文字である \001(Ctrl-A) を使用します。% や | といった印字可能文字は、URLエンコードされた文字列などJSONの内部に現れる可能性があり、行中に現れると jsonrow がそこで切れて正常な行を「壊れた行」として誤検出してしまいます。一方、JSON Linesの仕様において文字列内の制御文字は必ず \uXXXX 形式にエスケープされるため、生の \001 がデータ内に現れることはありません

-- JSONを、あえてCSVテーブルとして1列で読む
CREATE EXTERNAL TABLE json_validator (jsonrow string)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\001'   -- 制御文字なのでJSON Linesには現れない
LOCATION 's3://your-bucket/events_json/';
-- パースを試み、失敗した行とそのファイルパスを特定する
SELECT
  "$path"                    AS s3_file_path,   -- ← その行が来たS3ファイル
  jsonrow                    AS raw_line,       -- ← 壊れている生の行
  try(json_parse(jsonrow))   AS parsed
FROM json_validator
WHERE try(json_parse(jsonrow)) IS NULL
  AND jsonrow IS NOT NULL;

このクエリでは、以下の2つの機能が重要な役割を果たしています。

  • try(…) 関数 ― パース失敗時にエラーを発生させるのではなく、NULL を返します。これによりクエリの強制終了を防ぎつつ、try(json_parse(…)) IS NULL という条件で「パースできなかった行」だけを綺麗に抽出できます。
  • “$path” 疑似カラム ― 対象の行がどのS3ファイルから読み込まれたかを返します(明示的にSELECTしないと出てきません)。これにより、破損行とファイルパスの紐付けが可能になります。

実行結果は図4のようになります。壊れた行の生の内容と、その行があるファイルのパスが並びます。

図4:壊れた行が、生の内容とファイルパス付きで一覧化される

従来の挙動との最大の違いは、「不整合データの全量を一度に把握できる」点です。エラーメッセージが1回につき1ファイルしか教えてくれないのに対し、このクエリは条件に合致する破損行とファイルパスをすべて一覧化します。ファイルがいくつあろうとも、再実行を繰り返す必要はありません。「検査される前の生テキストとして読み解く」――これが、破損データに対する第一の手です。

エラー② Parquetの型混入 ― HIVE_BAD_DATA

今回の検証データ

ここでは、同一の amount カラムに対して、good.parquet では double 型、bad.parquet では string 型で書き込まれた2つのファイルを用意します。データ生成側のスキーマ変更などが原因で発生する、典型的な「型混入」の状況を再現します。

import pyarrow as pa, pyarrow.parquet as pq

pq.write_table(pa.table({"amount": pa.array([1.5, 2.5], pa.float64())}), "good.parquet")
pq.write_table(pa.table({"amount": pa.array(["oops", "!!"], pa.string())}), "bad.parquet")
# 2つを同じS3プレフィックスに置き、amount を DOUBLE で宣言したテーブルを作る

Data Catalog(テーブル定義)側は amount DOUBLE で宣言します。つまり、bad.parquet のデータだけがテーブル定義と食い違っている状態です。

どんなエラーか

このテーブルに対して amount カラムを読み込むクエリを実行すると、以下のエラーで停止します。

HIVE_BAD_DATA: Malformed Parquet file. Field amount's type BINARY in parquet file
s3://your-bucket/events_parquet/bad.parquet
is incompatible with type double defined in table schema
図5:Parquetファイルに複数の型が混在した場合のエラー

なぜ起きるのか ― そしてなぜ①の手が通じないのか

エラー①(JSON)では「すべてを一旦STRING型として読み込む」という回避策が有効でした。しかし、Parquetフォーマットにおいては、この手法は通用しません。

Parquetは、ファイル末尾の「フッター」と呼ばれる領域に、各カラムの物理的なデータ型(メタデータ)を保持しています。Athenaはデータ読み込み時に「ファイルに記録されている物理型」と「テーブル定義で宣言された型」の互換性を強制的にチェックします。物理的に double で書き込まれた列を、テーブル定義側で string と宣言しても、互換性がないと判断され上記の HIVE_BAD_DATA が発生します。どちらか一方の型にテーブル定義を合わせても、もう片方の型で書かれたファイルが必ず弾かれるため、テーブル定義の変更だけでは解決できないのがこの問題の難点です。

count(*) で無事を確認した気になる罠

ここで多くの開発者が陥りがちなのが、「ひとまず count(*) を実行して、テーブルが正常に動くか確認する」というアプローチです。

SELECT count(*) FROM events_parquet;   -- 壊れていても通ってしまう
図6:型が混入したテーブルでも count(*) は何事もなく成功する

図6に示す通り、内部の型が破損していてもこのクエリは正常に終了します。なぜなら、count(*) は実際の列データを一切デコードしないからです。Parquetの行数はフッターの num_rows から直接取得できるため、列の型不整合を検知できません。「count(*) が通ったからデータは健全である」という判断は、誤った安心感を生む原因になります。

型の不整合を確実に検知するには、SELECT * 相当でデータを実体化(デコード)させるしかありません(図5)。しかし、これには以下の「二重苦」が伴います。

  • データを実際にスキャンするため、Athenaのスキャン課金が発生する
  • 最初のエラーファイルでクエリ全体が中断するため、不整合を1組ずつしか検出できない

どの列が原因か分からない状況で、Athenaのクエリを愚直に回し続けるのは、時間的にもコスト的にも得策ではありません。

どう原因を特定するか ― フッターだけを読む

私が本当に知りたいのは、データの中身ではなく、「各ファイルがどのデータ型で書き込まれているか」というメタデータ情報です。これは、Parquetのデータ本体をスキャンせずとも、ファイル末尾のフッターだけを読み込めば解決します。

Athenaにはフッターのスキーマ情報を直接SQLで抽出する手段($schema のような疑似カラム)がないため、ここでは軽量なPythonスクリプトを用いて、S3上のParquetフッターを効率的に走査します。

import pyarrow.parquet as pq, s3fs
from concurrent.futures import ThreadPoolExecutor

BUCKET = "your-bucket"
MAX_WORKERS = 32

fs = s3fs.S3FileSystem(config_kwargs={"max_pool_connections": MAX_WORKERS})
files = sorted(fs.glob(f"{BUCKET}/events_parquet/*.parquet"))

expected = {
    "amount": "double",
}

def schema_of(p):
    s = pq.read_schema(p, filesystem=fs)
    return p, {f.name: str(f.type) for f in s}

with ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex:
    schemas = list(ex.map(schema_of, files))

print("=== 各ファイルの型 ===")
for p, m in schemas:
    print(p, m)

print("=== 期待型から外れた列 ===")
for p, m in schemas:
    for c, t in m.items():
        if c in expected and t != expected[c]:
            print(f"{p}: 列 '{c}' が {t}(期待: {expected[c]})")

実行すると、以下のように「どのファイルの・どの列が・どの型で外れているか」が一覧で出力されます。

=== 各ファイルの型 ===
athena-demo-20260608/events_parquet/bad.parquet {'amount': 'string'}
athena-demo-20260608/events_parquet/good.parquet {'amount': 'double'}
=== 期待型から外れた列 ===
athena-demo-20260608/events_parquet/bad.parquet:  'amount'  string(期待: double)

このスクリプトが効く理由

このアプローチの利点は、Athenaのスキャンと比べたときにはっきりします。

  • フッターしか読まないので、ファイルが大きくても速い。 Parquetのフッターはファイル末尾にあり、通常は数KB〜数十KB程度。スクリプトはS3のレンジ取得(Range GET)でこの末尾だけを読みます。1ファイルが数百MBでも数GBでも、読む量はフッターぶんだけなので、サイズに関係なく短時間で終わります。
  • ファイル数に対して線形、しかも並列。 ファイル間は独立なのでスレッドプールで一気に投げられ、数千ファイルでも数十秒です。
  • 全ファイル × 全カラムを一度に照合できる。 どの列が壊れているかを事前に特定する必要がありません。count(*) では決して分からなかった「どの列か」が、ここで確定します。
  • 反復も中断もない。 1パスで原因ファイルと原因カラムが全件出ます。

速度検証 ― 実際どのくらいで終わるのか

「フッターしか読まないので速い」と言われても、目安の数字がないとピンと来ないと思います。そこで、ファイル数を変えて上記スクリプトの実行時間を測りました。

検証条件

項目内容
ファイル約1KB、1MBのParquetファイル(Snappy圧縮)
ファイル数5個 / 500個 の2パターン
測定範囲fs.glob() によるファイル一覧の取得〜全ファイルのスキーマ照合完了まで
並列数max_workers=32
実行環境S3バケット:AWS 東京リージョン
スクリプト実行端末:M3 Mac PC
測定方法各パターン3回実行した平均値

検証用ファイルは、次のように同じスキーマのParquetを必要数だけ生成してアップロードしました(1ファイルが約1MBになるよう行数を調整しています)。

import pyarrow as pa, pyarrow.parquet as pq
import numpy as np

n_files, n_rows = 500, 80_000   # 約1MB/ファイルになるよう調整
for i in range(n_files):
    t = pa.table({
        "user_id": pa.array(np.random.randint(0, 10_000, n_rows)),
        "amount":  pa.array(np.random.rand(n_rows)),
    })
    pq.write_table(t, f"bench_{i:03}.parquet", compression="snappy")
# 生成後: aws s3 cp . s3://your-bucket/bench_parquet/ --recursive --exclude "*" --include "bench_*.parquet"

測定には time.perf_counter() を使い、スクリプトの前後を挟むだけです。

import time

start = time.perf_counter()
# (ここに前述のスクリプト本体)
elapsed = time.perf_counter() - start
print(f"{len(files)} files: {elapsed:.2f}s")

結果

ファイル数合計データサイズ処理時間(平均)
5個(1ファイル1KB)約5KB0.53 秒
500個(1ファイル1KB)約500KB3.13 秒
5個(1ファイル1MB)約5MB0.62 秒
500個(1ファイル1MB)約500MB3.62 秒

まずファイル数のスケールに注目してください。1ファイル1MBのケースで、ファイル数が100倍(5個→500個)になっても、処理時間は約5.8倍(0.62秒→3.62秒)にしか増えていません。処理はファイルごとに独立で、32並列で投げているため、所要時間を支配するのは「ファイル数 ÷ 並列数」で決まる“往復の回数”であって、ファイル数そのものではありません。32並列なら500ファイルでも16回程度の往復で読み切れるため、ファイル数の増加がそのまま時間に跳ね返らないわけです。

さらに重要なのが、1ファイルのサイズを1KBから1MB(1000倍)に増やしても、処理時間がほとんど変わらない点です(5個:0.53秒→0.62秒、500個:3.13秒→3.62秒)。読み取るのはどのファイルでもフッターの数KB〜数十KBだけで、データ本体には一切触れないため、ファイルが1KBでも1MBでも、さらに数百MB〜数GBでも、読む量はほぼ一定です。データ量に比例してスキャン時間と課金が膨らむAthenaの SELECT * とは対照的に、このアプローチの所要時間はファイルサイズからほぼ独立しています。

この特性により、1万ファイル規模でも「ファイル数 ÷ 並列数 × レイテンシ」から1分強で全容を特定できると見積もれます。大規模な現場でも、Athenaのクエリを1回待つのと大差ない時間で検証が完了します。

したがって、データ総量が数TB(テラバイト)を超えるような大規模環境であっても、このパフォーマンス特性は揺らぎません。 データ本体の「重さ」に処理速度が引きずられないため、データレイクの規模が大きくなればなるほど、この手法の優位性は圧倒的なものになります。

料金の比較

「どの列が壊れているか分からない」状態で、Athenaで無理に調べようとした場合と、フッタースクリプトの場合の料金を比べます。

Athenaで検出(SELECT * 相当)フッタースクリプト
読み取り対象列データ本体(全ファイル分)フッターのみ(数KB/ファイル)
Athenaスキャン料金発生($5.00/TB、最小10MB/クエリ)なし
S3 APIリクエスト料金GETリクエスト課金(ファイル数ぶん)GETリクエスト課金(ファイル数ぶん)
結果最初の1件で中断(全容が分からない)全件を一括で取得

ポイントは、スクリプト側は「Athenaのスキャン料金」が丸ごと無くなることです。発生するのはS3のGETリクエスト料金(おおよそ100万リクエストで $0.4 程度)だけ。読み取るデータ量自体もフッターぶんしかないため、スキャン量に対する課金がほぼゼロになります。Athenaでデータを実体化してスキャンすると、データ量に応じた $5.00/TB がそのまま乗ってくるうえ、中断して全容すら掴めません。速度でもコストでも、メタデータだけを読むスクリプトに分があります。

そもそも「データ量が膨大であること」を前提に置くならば、検証のためにデータを実体化させて探すアプローチそのものを見直す必要があります。データレイクの規模が大きくなるほど、エラーのたびに重たいクエリを回して試行錯誤を繰り返す戦い方は通用しなくなります。データが巨大だからこそ、本体には一切触れずにメタデータだけで問題を完結させる。この「データを読まない」という選択こそが、大規模データ運用における最も本質的な防衛策と言えます。

どう直すか

原因ファイルと原因カラムが分かったら、状況に応じて直します。

状況対策
問題ファイルを再生成できる正しい型で書き直し、古いファイルを削除(または別プレフィックスへ退避)
数値型どうしの差(int↔bigint など)テーブル側を広いほうの型で宣言すると吸収できる場合がある(Engine v3の型コアーション)。ただし数値↔文字列のような型ファミリー跨ぎは不可
HIVE_PARTITION_SCHEMA_MISMATCH の場合パーティション側のスキーマがテーブルと食い違っている状態。該当パーティションを削除して再追加するか、Glueクローラの「テーブルのメタデータで全パーティションを更新する」オプションを有効にして再実行
今後も混入リスクがあるテーブルフォーマット自体の見直しも選択肢(後述)

なお、そもそもスキーマ変更に強いテーブルフォーマットへ移すという選択肢もあります。たとえば Apache Iceberg はスキーマの進化(型変更や列の追加・削除)を履歴として管理する仕組みを備えており、型混入のような問題を構造的に起こりにくくできます。ただし移行には相応の設計・運用コストが伴い、本記事の主題(既存テーブルでエラー原因を素早く特定する)からは外れるため、ここでは「そういう選択肢もある」程度の紹介に留めます。採否はチームの要件次第です。

まとめ ― エラーの曖昧さに付き合わない

今回取り上げた2つのエラーは、表面的には異なるメッセージを出力しますが、その解決へのアプローチには共通する思想があります。

エラー本質突破口
壊れたJSON行ファイルは分かるが、行は分からず、1回に1件しか出ないCSV1列として無検査で読み、try(json_parse()) + “$path” で原因行を全件一覧化
Parquet型混入読まないと型が分からないが、読むと落ちるデータではなくフッター(メタデータ)を読み、全列を一括照合

双方に共通しているのは、「目の前で発生した個別のエラーメッセージにその都度対処するのをやめ、全体像を一度に俯瞰できる一段高い視点に立つ」 という発想の転換です。

  • 破損した行は、パースというフィルターにかける前の「生のテキスト」として扱えば、SQL側で安全に特定できる。
  • 型の不整合は、重たいデータ本体ではなく、それらを定義している「メタデータ」だけを読めば、コストをかけずに特定できる。

Athenaが謎のエラーを吐き出したとき、闇雲にファイルを1つずつダウンロードして中身を覗き始める必要はありません。一呼吸おいて、「このエラーの裏側にある構造は何だろうか」「それを最小のコストで一網打尽にできる切り口はどこにあるだろうか」と考えてみてください。たったそれだけで、精神を消耗する数時間のトラブルシューティングが、数分で終わるスマートなタスクへと変わるはずです。エラーメッセージに振り回される側から、エラーを淡々と潰していく側へ。次にAthenaが謎のエラーを吐いたときは、ぜひこの2つの引き出しを思い出してみてください。

投稿者プロフィール

秋田 浩也
秋田 浩也
BS事業部の秋田です。
PythonやAWS、Linuxを中心にシステム開発やインフラ設計に携わっています。
日々の業務や学びの中で得た知見をQiitaなどで発信しており、現場で役立つ技術やノウハウをわかりやすく共有することを心がけています。
この記事をSNSでシェア!