- Published on
PyArrow ArrowInvalid - cannot mix list and non-list 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그나 배치 파이프라인에서 Parquet/Feather로 저장하거나, pandas를 Arrow 기반으로 변환하는 순간 갑자기 터지는 에러가 있습니다. 바로 ArrowInvalid: cannot mix list and non-list, non-null values 입니다.
이 오류는 요약하면 같은 컬럼(또는 같은 필드)에 어떤 행은 리스트(배열)이고 어떤 행은 스칼라(단일 값) 인 상태로 Arrow 배열을 만들려고 할 때 발생합니다. Arrow는 컬럼 단위로 타입이 고정되는 컬럼형 포맷이기 때문에, 한 컬럼에서 list 와 non-list 를 섞는 것을 허용하지 않습니다.
이 글에서는 (1) 왜 이런 혼합이 생기는지, (2) 어디서 많이 터지는지, (3) 재현 코드, (4) 실무에서 가장 안전한 해결 패턴(정규화, 스키마 고정, null 처리)을 단계별로 정리합니다.
오류가 발생하는 전형적인 상황
다음 중 하나라도 해당하면 거의 확실히 이 오류를 만납니다.
1) JSON/로그 원천 데이터의 스키마가 흔들릴 때
- 어떤 이벤트는
tags: ["a", "b"] - 어떤 이벤트는
tags: "a" - 어떤 이벤트는
tags: null
원천에서 이미 타입이 흔들리면, pandas DataFrame에서는 object 로 뭉개져 보이지만 Arrow 변환 시점에 폭발합니다.
2) pandas에서 to_parquet(engine="pyarrow") 저장 시
pandas는 내부적으로 pyarrow에 컬럼을 넘겨 Arrow 배열로 만듭니다. 이때 컬럼 값이 리스트/스칼라 혼합이면 에러가 납니다.
3) pyarrow.Table.from_pandas 또는 pa.array 호출 시
직접 Arrow 배열을 만들 때도 동일합니다.
최소 재현 코드 (Repro)
아래는 가장 작은 재현입니다. 같은 컬럼에 리스트와 정수가 섞여 있습니다.
import pyarrow as pa
values = [[1, 2], 3, [4]]
pa.array(values)
대부분의 환경에서 아래와 유사한 예외가 납니다.
ArrowInvalid: cannot mix list and non-list, non-null values
pandas에서도 비슷합니다.
import pandas as pd
df = pd.DataFrame({
"items": [["a", "b"], "c", ["d"]]
})
df.to_parquet("out.parquet", engine="pyarrow")
핵심 원인: Arrow는 컬럼 타입이 단일해야 한다
Arrow에서 컬럼은 예를 들어 다음처럼 단일 타입 이어야 합니다.
list<item: string>(문자열 리스트)string(단일 문자열)
그런데 한 컬럼에
- 어떤 행은
list<string> - 어떤 행은
string
이 섞이면, Arrow는 이 컬럼을 어떤 타입으로 만들어야 할지 결정할 수 없습니다. 자동 추론은 여기서 실패합니다.
따라서 해결은 항상 둘 중 하나입니다.
- 컬럼을 리스트 컬럼으로 통일 (스칼라는 리스트로 감싸기)
- 컬럼을 스칼라 컬럼으로 통일 (리스트는 join하거나 첫 원소만 쓰거나 별도 컬럼으로 분리)
실무에서는 1번(리스트로 통일)이 데이터 보존 측면에서 안전한 경우가 많습니다.
해결 전략 1: 정규화로 리스트 컬럼 통일 (가장 흔한 정답)
문자열/숫자 스칼라를 리스트로 감싸기
import pandas as pd
def normalize_to_list(x):
if x is None:
return None
if isinstance(x, list):
return x
# 튜플/셋이 올 수도 있으면 여기서 list로 변환
if isinstance(x, (tuple, set)):
return list(x)
# 스칼라는 단일 원소 리스트로
return [x]
df = pd.DataFrame({
"items": [["a", "b"], "c", None, ["d"]]
})
df["items"] = df["items"].map(normalize_to_list)
df.to_parquet("out.parquet", engine="pyarrow")
이 방식은 items 컬럼을 사실상 list<string> 로 통일시킵니다.
빈 리스트와 null의 의미를 구분하기
None은 결측(unknown/absent)[]는 값이 있지만 원소가 0개
분석에서 의미가 다를 수 있으니, 비즈니스 정의에 맞춰 통일하세요.
예: 결측도 빈 리스트로 보고 싶으면
def normalize_to_list_empty_for_null(x):
if x is None:
return []
if isinstance(x, list):
return x
return [x]
해결 전략 2: 스키마를 명시해서 타입 추론을 막기
정규화가 끝났다면, 다음 단계로 스키마를 명시하면 파이프라인 안정성이 크게 올라갑니다. 특히 데이터가 커지고 컬럼이 많을수록 “추론”은 장애 포인트입니다.
pyarrow.Schema 로 리스트 타입 고정
import pyarrow as pa
import pyarrow.parquet as pq
import pandas as pd
df = pd.DataFrame({
"user_id": [1, 2, 3],
"items": [["a", "b"], ["c"], None],
})
schema = pa.schema([
("user_id", pa.int64()),
("items", pa.list_(pa.string())),
])
table = pa.Table.from_pandas(df, schema=schema, preserve_index=False)
pq.write_table(table, "out.parquet")
포인트는 ("items", pa.list_(pa.string())) 처럼 리스트 타입을 고정하는 것입니다.
- 정규화 없이 스키마만 강제하면, 변환 과정에서 다른 타입이 들어온 행에서 또 다른 예외가 날 수 있습니다.
- 따라서 실무에서는 정규화 + 스키마 명시 조합이 가장 안전합니다.
해결 전략 3: 리스트를 스칼라로 변환 (정책이 명확할 때)
데이터 모델 상 “원래는 단일 값”이 맞는데, 일부 데이터가 실수로 리스트로 들어온 경우라면 스칼라로 통일하는 편이 낫습니다.
첫 원소만 취하기
def coerce_list_to_scalar_first(x):
if x is None:
return None
if isinstance(x, list):
return x[0] if len(x) > 0 else None
return x
df["item"] = df["item"].map(coerce_list_to_scalar_first)
join해서 문자열로 저장하기 (검색/표시 목적)
def coerce_list_to_csv(x):
if x is None:
return None
if isinstance(x, list):
return ",".join(map(str, x))
return str(x)
df["items_csv"] = df["items"].map(coerce_list_to_csv)
이 방식은 분석(예: explode) 관점에서는 손해가 있을 수 있으니, 목적이 명확할 때만 추천합니다.
해결 전략 4: 저장 전에 검증(Validation)으로 조기 실패시키기
장애를 “저장 시점”에서 처음 발견하면 이미 늦은 경우가 많습니다. 배치라면 이미 몇 시간 돌았을 수 있고, 스트리밍이라면 일부 파티션만 깨졌을 수 있습니다.
따라서 Arrow 변환 직전에 컬럼 혼합 여부를 체크해 조기 실패시키는 것이 좋습니다.
import pandas as pd
def assert_list_or_null(series: pd.Series, col_name: str):
bad = series.dropna().map(lambda x: not isinstance(x, list))
if bad.any():
idx = bad[bad].index[:5].tolist()
samples = series.loc[idx].tolist()
raise ValueError(f"{col_name} has non-list values at rows {idx}: {samples}")
df = pd.DataFrame({"items": [[1], 2, None]})
assert_list_or_null(df["items"], "items")
이렇게 하면 pyarrow 예외 대신, 내가 의도한 메시지로 빠르게 원인을 특정할 수 있습니다.
실무 팁: 어디에서 섞였는지 추적하는 방법
1) 원천 JSON 스키마 드리프트를 의심하라
특히 이벤트 트래킹/서드파티 웹훅/로그 수집에서 흔합니다. 같은 키가 상황에 따라 문자열이 되기도, 배열이 되기도 합니다.
이럴 때는 수집 단계에서부터 정규화하는 것이 가장 좋습니다.
2) pandas의 object 컬럼은 “문제 숨김”일 뿐이다
pandas는 object 에 뭐든 담기 때문에, 중간 단계에서는 멀쩡해 보입니다. 하지만 Arrow/Parquet은 타입이 엄격해서 마지막에 터집니다.
비슷한 맥락의 경고로 SettingWithCopyWarning 같은 것도 “지금은 돌아가지만 나중에 문제”를 만드는 대표 사례입니다. pandas 경고/예외를 초기에 잡는 습관이 파이프라인 안정성을 올립니다.
자주 묻는 케이스별 처방전
Q1. 리스트 컬럼인데 어떤 행은 단일 문자열이다
- 처방: 단일 문자열을
[문자열]로 감싸서 리스트로 통일
Q2. 리스트 컬럼인데 어떤 행은 딕셔너리(객체)다
- 처방: 리스트 원소 타입도 통일해야 합니다. 예를 들어
list<struct>로 갈지, JSON 문자열로 저장할지 결정 후 정규화
Q3. null 때문에 문제인가
- 보통 null 자체는 문제가 아닙니다. 문제는 null이 아닌 값들 사이에서 list/non-list가 섞이는 것 입니다.
결론: “정규화 + 스키마 고정”이 가장 안전하다
ArrowInvalid: cannot mix list and non-list 는 단순히 pyarrow가 까다로워서가 아니라, 데이터 모델이 흔들리고 있다는 신호입니다.
추천하는 표준 대응은 다음 순서입니다.
- 컬럼 정책 결정: 리스트로 갈지, 스칼라로 갈지
- ingest 또는 저장 직전에 정규화로 타입 통일
pyarrow.Schema로 타입을 명시해 추론을 제거- 변환 직전 검증으로 조기 실패 및 원인 추적
이 패턴을 적용하면 Parquet 저장뿐 아니라, Arrow 기반 엔진(예: DuckDB, Polars, Spark Arrow 최적화 경로)과 연동할 때도 같은 유형의 장애를 크게 줄일 수 있습니다.
추가로, 장애를 “증상”이 아니라 “원인”에서 끊는 접근은 인프라/배포 쪽에서도 동일하게 유효합니다. 예를 들어 크래시 루프처럼 겉으로는 재시작만 보이지만 실제 원인이 따로 있는 문제는 로그 기반으로 빠르게 좁혀야 합니다.