- Published on
pandas SettingWithCopyWarning 완벽 해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
pandas를 쓰다 보면 한 번쯤은 다음 경고를 만나게 됩니다.
> SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame
이 경고는 단순히 “시끄러운 경고”가 아니라, 내가 수정했다고 믿는 값이 실제 원본 DataFrame에 반영되지 않을 수 있음을 알려주는 신호입니다. 특히 체이닝 인덱싱(chained indexing)과 슬라이싱 결과가 view(원본을 바라보는 얕은 객체) 인지 copy(복사본) 인지 pandas가 확신하지 못할 때 발생합니다.
이 글에서는 SettingWithCopyWarning의 핵심 원리를 짚고, 현업에서 가장 자주 쓰는 완벽 해결 7가지 패턴을 코드로 정리합니다. (경고를 “끄는” 방법도 다루지만, 최후의 수단으로만 권장합니다.)
참고로, 장애 원인-해결을 체크리스트로 정리하는 스타일이 익숙하다면 OpenAI Responses API 502 Bad Gateway 원인과 해결 같은 글의 접근 방식과 유사하게, 이 글도 “원인 → 안전한 조치” 순서로 진행합니다.
SettingWithCopyWarning이 발생하는 이유(정확한 메커니즘)
핵심은 아래 한 줄입니다.
df[조건][컬럼] = 값같은 체이닝 인덱싱은 중간 결과(df[조건])가 view인지 copy인지 보장되지 않습니다.
즉, 다음 코드는 “될 때도 있고 안 될 때도” 있습니다.
import pandas as pd
df = pd.DataFrame({
"name": ["a", "b", "c", "d"],
"score": [10, 60, 30, 80]
})
# 문제 패턴: chained indexing
# df[df["score"] >= 50] 가 view/copy 무엇인지 불명확
df[df["score"] >= 50]["grade"] = "pass" # SettingWithCopyWarning 가능
이 경고의 무서운 점은 경고가 떠도 결과가 우연히 맞아 보일 수 있다는 것입니다. 하지만 데이터 파이프라인/피처 엔지니어링에서 이런 “우연”은 재현 불가능한 버그로 변합니다.
해결 1) .loc로 단일 단계에서 대입하기(정석)
가장 권장되는 해결책은 .loc[행조건, 열] = 값 형태로 한 번에 대입하는 것입니다.
import pandas as pd
df = pd.DataFrame({
"name": ["a", "b", "c", "d"],
"score": [10, 60, 30, 80]
})
mask = df["score"] >= 50
df.loc[mask, "grade"] = "pass"
df.loc[~mask, "grade"] = "fail"
print(df)
- 장점: 경고 없음, 의도가 명확, 성능도 안정적
- 팁: 열이 여러 개면 리스트로 지정
cols = ["grade", "flag"]
df.loc[mask, cols] = ["pass", 1]
해결 2) 필터링한 결과를 ‘명시적으로’ .copy()로 만들기
슬라이스 결과를 독립적으로 다룰 목적이라면, 처음부터 복사본임을 선언하세요.
passed = df[df["score"] >= 50].copy()
passed["grade"] = "pass" # 경고 없음
- 언제 쓰나: “원본 df는 그대로 두고”, 부분집합을 따로 가공/저장/리포팅할 때
- 주의:
.copy()는 메모리를 사용합니다. 큰 데이터면 필요한 컬럼만 먼저 줄여서 복사하세요.
passed = df.loc[df["score"] >= 50, ["name", "score"]].copy()
해결 3) .assign()으로 체인형 파이프라인을 안전하게 만들기
메서드 체이닝을 즐겨 쓴다면 .assign()이 깔끔합니다. .assign()은 새 DataFrame을 반환하며, 경고를 피하기 쉽습니다.
import numpy as np
out = (
df
.assign(grade=lambda x: np.where(x["score"] >= 50, "pass", "fail"))
)
print(out)
- 장점: 함수형 파이프라인에 잘 맞음
- 주의:
out은 새 객체이므로 원본df를 바꾸려면df = out처럼 재할당하세요.
해결 4) where/mask로 조건부 대입(브로드캐스팅 안전)
특정 컬럼에 조건부로 값을 채우는 경우, .where() 또는 .mask()가 간결하고 안전합니다.
import numpy as np
df = df.copy()
df["grade"] = "fail"
df["grade"] = df["grade"].mask(df["score"] >= 50, "pass")
# 또는
# df["grade"] = df["grade"].where(df["score"] < 50, "pass")
print(df)
.mask(cond, value)는 cond가 True인 곳을 value로 바꿈.where(cond, value)는 cond가 False인 곳을 value로 바꿈
해결 5) merge/join으로 “원본에 덧씌우기” 패턴 사용
부분집합에서 계산한 결과를 원본에 안전하게 반영하려면, 인덱스/키를 기준으로 다시 합치는 방식이 강력합니다.
# 예: score>=50인 사람에게 bonus 부여
passed = df.loc[df["score"] >= 50, ["name"]].copy()
passed["bonus"] = 100
# name을 키로 병합
out = df.merge(passed, on="name", how="left")
out["bonus"] = out["bonus"].fillna(0).astype(int)
print(out)
- 장점: 복잡한 파생 컬럼도 안전하게 합칠 수 있음
- 주의: 키 중복/조인 카디널리티(1:1, 1:N)를 반드시 확인
해결 6) 그룹 연산은 transform/agg 결과를 .loc로 대입
groupby() 이후 일부 행만 수정하려고 하면 경고를 유발하기 쉽습니다. 그룹 기반 파생값은 transform으로 원본 길이에 맞춰 만든 뒤 .loc로 대입하세요.
df = pd.DataFrame({
"team": ["A", "A", "B", "B"],
"score": [10, 60, 30, 80]
})
# 팀 평균을 각 행에 맞춰 확장
team_mean = df.groupby("team")["score"].transform("mean")
df.loc[:, "team_mean"] = team_mean
# 팀 평균 이상이면 1
df.loc[:, "above_mean"] = (df["score"] >= df["team_mean"]).astype(int)
print(df)
- 포인트:
transform은 결과 길이가 원본과 같아서 대입이 안전
해결 7) (최후의 수단) 경고를 끄지 말고, 원인을 추적하거나 옵션을 제한적으로 사용
가끔 레거시 코드/노트북에서 경고가 너무 많이 떠서 “일단 끄고 싶다”는 요구가 있습니다. 하지만 SettingWithCopyWarning은 실제 버그를 가릴 수 있으므로 비추천입니다.
그래도 불가피하다면, 최소한 다음 원칙을 지키세요.
- 경고를 끄기 전에, 문제 라인을
.loc또는.copy()로 고친다. - 정말 외부 라이브러리 내부에서만 발생한다면, 컨텍스트 범위를 좁혀서 무시한다.
import warnings
import pandas as pd
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=pd.errors.SettingWithCopyWarning)
# 외부 함수 호출 등, 통제 불가능한 구간만 감싸기
# external_library.do_something(df)
또는 전역 옵션(권장하지 않음):
pd.options.mode.chained_assignment = None # 'warn'(기본), 'raise', None
- 디버깅 팁: 개발/테스트 환경에서는 오히려
raise로 올려서 조기 발견하는 것도 좋습니다.
pd.options.mode.chained_assignment = "raise"
자주 터지는 패턴과 “안전한 대체” 빠른 치트시트
1) df[cond][col] = value → df.loc[cond, col] = value
# bad
# df[df["score"] >= 50]["grade"] = "pass"
# good
mask = df["score"] >= 50
df.loc[mask, "grade"] = "pass"
2) 필터 결과를 수정하고 원본도 바뀌길 기대 → 처음부터 원본에 .loc
subset = df[df["score"] >= 50]
subset["x"] = 1 # subset만 바뀔 수도
# 원본을 바꾸려면
mask = df["score"] >= 50
df.loc[mask, "x"] = 1
3) “부분집합을 따로 저장/가공” 목적 → .copy()
report = df.loc[df["score"] >= 50].copy()
report["tag"] = "report"
디버깅 체크리스트(경고가 떴을 때 3분 진단)
- 대입 라인이 체이닝 인덱싱인지 확인:
][형태가 보이면 거의 확정 - 원본을 바꾸려는지, 복사본을 만들려는지 의도를 결정
- 원본 변경:
.loc[...] = ... - 복사본 작업:
.copy()후 수정
- 원본 변경:
- 인덱스 정합성 확인: 마스크(
mask)가 원본 df와 같은 인덱스를 기준으로 만들어졌는가? - 함수형 파이프라인이면
.assign()고려 - groupby 결과는
transform으로 길이 맞추기
이런 식의 “원인-증상-해결” 접근은 인프라/런타임 트러블슈팅에서도 동일하게 통합니다. 예를 들어 EKS Pod→RDS 504 타임아웃 - SG·NACL·NAT 10분 진단처럼, 증상(504)만 보고 우회(타임아웃 증가)하기보다 근본 원인(SG/NACL/NAT)을 먼저 잡는 방식이죠. SettingWithCopyWarning도 “경고 끄기”가 아니라 “뷰/복사 불명확성 제거”가 정답입니다.
결론
SettingWithCopyWarning을 완벽히 해결하는 핵심은 단 하나입니다.
- 대입은 항상 단일 단계로(=
.loc) - 부분집합을 독립적으로 다룰 땐
.copy()로 의도를 명시
여기에 .assign, mask/where, merge/join, transform까지 익혀두면 대부분의 데이터 전처리 파이프라인에서 경고 없이, 그리고 무엇보다 예측 가능한 결과를 얻을 수 있습니다.
원한다면, 실제로 자주 등장하는 “전처리 함수 템플릿(입력 df를 변경하지 않는 순수 함수형 스타일)”로 SettingWithCopyWarning을 원천 차단하는 구조도 예제로 확장해드릴 수 있습니다.