Published on

PyArrow ArrowInvalid - cannot mix list and non-list 해결

Authors

서버 로그나 배치 파이프라인에서 Parquet/Feather로 저장하거나, pandas를 Arrow 기반으로 변환하는 순간 갑자기 터지는 에러가 있습니다. 바로 ArrowInvalid: cannot mix list and non-list, non-null values 입니다.

이 오류는 요약하면 같은 컬럼(또는 같은 필드)에 어떤 행은 리스트(배열)이고 어떤 행은 스칼라(단일 값) 인 상태로 Arrow 배열을 만들려고 할 때 발생합니다. Arrow는 컬럼 단위로 타입이 고정되는 컬럼형 포맷이기 때문에, 한 컬럼에서 listnon-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는 이 컬럼을 어떤 타입으로 만들어야 할지 결정할 수 없습니다. 자동 추론은 여기서 실패합니다.

따라서 해결은 항상 둘 중 하나입니다.

  1. 컬럼을 리스트 컬럼으로 통일 (스칼라는 리스트로 감싸기)
  2. 컬럼을 스칼라 컬럼으로 통일 (리스트는 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가 까다로워서가 아니라, 데이터 모델이 흔들리고 있다는 신호입니다.

추천하는 표준 대응은 다음 순서입니다.

  1. 컬럼 정책 결정: 리스트로 갈지, 스칼라로 갈지
  2. ingest 또는 저장 직전에 정규화로 타입 통일
  3. pyarrow.Schema 로 타입을 명시해 추론을 제거
  4. 변환 직전 검증으로 조기 실패 및 원인 추적

이 패턴을 적용하면 Parquet 저장뿐 아니라, Arrow 기반 엔진(예: DuckDB, Polars, Spark Arrow 최적화 경로)과 연동할 때도 같은 유형의 장애를 크게 줄일 수 있습니다.

추가로, 장애를 “증상”이 아니라 “원인”에서 끊는 접근은 인프라/배포 쪽에서도 동일하게 유효합니다. 예를 들어 크래시 루프처럼 겉으로는 재시작만 보이지만 실제 원인이 따로 있는 문제는 로그 기반으로 빠르게 좁혀야 합니다.