Published on

pandas SettingWithCopyWarning 안전 수정 7가지

Authors

서론

pandas를 쓰다 보면 한 번쯤은 아래와 같은 경고를 마주칩니다.

> SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame

이 경고는 단순히 “짜증나는 메시지”가 아니라, 내 코드가 실제로 원본 DataFrame을 수정했는지 보장할 수 없다는 신호입니다. 특히 필터링한 결과에 값을 대입하는 패턴(체인 인덱싱, chained indexing)이 섞이면, 어떤 경우에는 값이 바뀐 것처럼 보이지만 실제로는 원본이 그대로이거나, 반대로 일부만 바뀌는 상황이 생깁니다.

이 글에서는 SettingWithCopyWarning의 원리를 짧게 짚고, 현업에서 재현 가능한 형태로 안전하게 수정하는 7가지 패턴을 코드와 함께 정리합니다. (경고를 끄는 방식은 마지막에 “왜 비추천인지”도 함께 다룹니다.)

추가로, pandas에서 조인/머지 이후 데이터가 예상과 다르게 불어나는 문제도 자주 함께 등장합니다. 머지 후 NaN 폭증이나 행 수 증가가 의심된다면 Pandas merge 후 NaN 폭증·행수 증가 원인 7가지도 같이 보면 디버깅 속도가 크게 올라갑니다.

SettingWithCopyWarning이 발생하는 이유(핵심만)

문제의 핵심은 다음입니다.

  • df[mask] 또는 df[['col1','col2']] 같은 슬라이싱 결과가
    • 원본을 “바라보는 뷰(view)”일 수도 있고
    • 완전히 분리된 “복사(copy)”일 수도 있습니다.
  • pandas는 내부 최적화/상황에 따라 뷰/복사 여부가 달라질 수 있어, 체인 인덱싱으로 값을 세팅하면 그 결과가 원본에 반영된다는 보장이 없습니다.

대표적인 위험 패턴:

# 위험: 체인 인덱싱 (필터 -> 컬럼 선택 -> 대입)
df[df["age"] >= 20]["group"] = "adult"

위 코드는 경고가 뜨기도 하고, 안 뜨기도 하며, 뜨지 않아도 논리적으로 안전하지 않습니다.

1) .loc로 한 번에 선택하고 한 번에 대입하기(정석)

가장 권장되는 방법은 행 조건과 열 선택을 .loc에 함께 넣어 단일 연산으로 대입하는 것입니다.

import pandas as pd

df = pd.DataFrame({
    "name": ["a", "b", "c"],
    "age": [10, 20, 30],
    "group": [None, None, None]
})

mask = df["age"] >= 20

df.loc[mask, "group"] = "adult"
print(df)
  • 장점: 경고 없음, 의도 명확, 성능도 좋은 편
  • 팁: 여러 컬럼을 동시에 세팅할 때도 .loc가 가장 깔끔합니다.
df.loc[mask, ["group", "name"]] = ["adult", "X"]

2) 필터링한 결과를 ‘새 DataFrame’으로 쓸 거면 .copy()를 명시하기

원본을 수정할 생각이 없고, 필터링 결과를 별도 테이블로 다룰 거라면 처음부터 복사본임을 명시하세요.

adult_df = df.loc[df["age"] >= 20].copy()
adult_df["group"] = "adult"  # 경고 없이 안전
  • 핵심: “난 원본을 바꾸지 않을 거야”라는 의도를 코드로 고정
  • 주의: .copy()는 메모리를 더 사용합니다. 큰 데이터면 필요한 컬럼만 선택해서 복사하세요.
adult_df = df.loc[df["age"] >= 20, ["name", "age"]].copy()

3) .assign으로 파이프라인을 유지하며 안전하게 컬럼 생성/변환

체인 형태의 전처리 파이프라인을 선호한다면, 대입 대신 .assign()을 활용해 새 DataFrame을 반환하는 방식이 안전합니다.

adult_df = (
    df
    .loc[df["age"] >= 20]
    .assign(group="adult")
)

조건에 따라 값을 넣고 싶다면 np.where 또는 Series.where를 함께 씁니다.

import numpy as np

df = df.assign(
    group=np.where(df["age"] >= 20, "adult", "minor")
)
  • 장점: 함수형 스타일, 중간 상태를 덜 만들고 가독성 좋음
  • 단점: 원본 수정이 아니라 “재할당”이므로, df = ... 패턴을 유지해야 함

4) .where / .mask로 조건부 대입을 ‘벡터화’로 처리

조건부로 일부 값만 바꾸는 경우 .loc도 좋지만, .where/.mask가 더 읽기 쉬운 경우가 많습니다.

# age>=20이면 group 유지, 아니면 'minor'
df["group"] = df["group"].where(df["age"] >= 20, "minor")

# 또는 반대로: 조건을 만족하면 바꾸고, 아니면 유지
# mask는 조건이 True인 곳을 바꿈
df["group"] = df["group"].mask(df["age"] >= 20, "adult")
  • 장점: 명시적이고 빠른 편(벡터 연산)
  • 주의: 좌변이 df[mask][col] 같은 체인 인덱싱이면 다시 위험해집니다. 좌변은 항상 df[col] 또는 df.loc[...]로.

5) .query() + .loc 조합으로 가독성 높이기(단, 대입은 loc로)

query()는 조건식이 길어질 때 읽기 좋습니다. 하지만 대입을 query() 결과에 바로 하면 다시 체인 인덱싱이 되기 쉽습니다. 선택은 query, 대입은 loc로 고정하세요.

idx = df.query("age >= 20").index

df.loc[idx, "group"] = "adult"
  • 장점: 조건이 복잡할 때 문자열로 정리 가능
  • 단점: query() 문자열은 컬럼명/로컬 변수 참조 규칙을 알아야 함

6) 정렬/부분 선택 후 대입이 잦다면, 인덱스 정합성을 먼저 고정하기

SettingWithCopyWarning이 자주 터지는 코드에는 보통 이런 흐름이 있습니다.

  1. 정렬/필터링/부분 컬럼 선택
  2. 그 결과에 또 대입

이때 “원본과의 관계”가 애매해지기 쉽습니다. 해결책은 두 가지 중 하나입니다.

(A) 원본을 수정할 거면: 인덱스를 유지한 채 loc로 대입

sorted_df = df.sort_values("age")
mask = sorted_df["age"] >= 20

# sorted_df는 df의 재배열 결과일 뿐이므로,
# 원본 df에 반영하고 싶다면 인덱스로 df.loc에 대입

df.loc[sorted_df.index[mask], "group"] = "adult"

(B) 정렬/부분 선택 결과를 독립적으로 다룰 거면: copy 후 수정

sorted_df = df.sort_values("age").copy()
sorted_df.loc[sorted_df["age"] >= 20, "group"] = "adult"
  • 요령: “원본에 반영할 건지”를 먼저 결정하고, 그에 맞게 df.loc[...] 또는 .copy()를 선택

7) 경고를 ‘끄지 말고’, 재현 가능한 테스트로 의도를 고정하기

SettingWithCopyWarning을 무시하고 아래처럼 전역 옵션으로 끄는 글도 종종 보입니다.

pd.options.mode.chained_assignment = None  # 비추천

이건 증상을 숨길 뿐이고, 데이터가 잘못 수정되는 버그를 더 늦게 발견하게 만듭니다. 대신 아래를 추천합니다.

(A) 경고를 에러로 승격해 개발 중 즉시 잡기

테스트/개발 환경에서만이라도 경고를 에러로 바꾸면, 위험한 체인 인덱싱을 초기에 제거할 수 있습니다.

import pandas as pd

pd.options.mode.chained_assignment = "raise"  # 개발/테스트에서 권장

# 이후 체인 인덱싱 대입이 발생하면 SettingWithCopyError로 즉시 실패

(B) 전처리 함수에 대해 ‘원본이 바뀌는지/안 바뀌는지’ 테스트 작성

예: 원본을 변경하지 않는 함수라면 df.copy(deep=True)를 넣고 비교합니다.

import pandas as pd
from pandas.testing import assert_frame_equal

def build_adult_df(df: pd.DataFrame) -> pd.DataFrame:
    return df.loc[df["age"] >= 20, ["name", "age"]].copy().assign(group="adult")

orig = pd.DataFrame({"name": ["a", "b"], "age": [10, 30]})
orig_backup = orig.copy(deep=True)

out = build_adult_df(orig)

assert_frame_equal(orig, orig_backup)  # 원본 불변 보장
assert (out["group"] == "adult").all()
  • 이 접근은 데이터 파이프라인이 커질수록 효과가 큽니다.

자주 묻는 함정 3가지(빠른 체크리스트)

1) df2 = df[mask]df2[col] = ... 했는데 원본이 안 바뀌어요

정상입니다. df2는 복사일 수 있습니다. 원본을 바꾸려면:

df.loc[mask, col] = value

2) df[df[mask]][col] = ... 같은 코드는 왜 위험하죠?

슬라이싱을 두 번 연속으로 하면서 중간 결과가 뷰/복사인지 불명확해집니다. 항상 .loc[rows, cols]로 한 번에.

3) 경고가 안 뜨면 안전한 거 아닌가요?

아닙니다. 경고는 상황에 따라 안 뜰 수 있고, 뜨지 않아도 결과가 비결정적으로 변할 수 있습니다. “안전”은 코드 패턴으로 확보해야 합니다.

결론: ‘원본 수정’은 loc, ‘파생 테이블’은 copy/assign

SettingWithCopyWarning을 안전하게 없애는 핵심 규칙은 간단합니다.

  • 원본 DataFrame을 수정하려면: df.loc[조건, 컬럼] = 값
  • 필터링 결과를 별도 테이블로 쓸 거면: .copy() 또는 .assign()으로 독립성 확보
  • 경고를 끄지 말고: 개발/테스트에서 **chained_assignment='raise'**로 조기 발견

실무에서는 이 경고가 “데이터가 조용히 틀려지는” 버그의 입구가 되는 경우가 많습니다. 특히 merge 이후 필터링/대입을 연속으로 수행하는 파이프라인이라면, 앞서 소개한 머지 이슈 정리 글(Pandas merge 후 NaN 폭증·행수 증가 원인 7가지)과 함께 점검하면, 원인 추적 시간이 크게 줄어듭니다.