Python으로 표 형태의 데이터를 다룰 때 가장 자주 사용하는 도구가 Pandas DataFrame이다.
DataFrame을 익힐 때는 메서드를 하나씩 외우기보다 실제 데이터 처리 순서로 연결해서 보는 편이 이해하기 쉽다.
데이터를 불러온다 → 구조를 확인한다 → 필요한 행과 열을 조회한다 → 값을 변경한다 → 결측치를 처리한다 → 그룹별로 집계한다 → 다른 데이터와 연결한다.
이 글에서는 DataFrame을 처음 다루는 입장에서 꼭 필요한 문법을 이 흐름에 맞춰 정리한다.
DataFrame은 데이터베이스의 테이블이나 엑셀 시트처럼 행(row) 과 열(column) 로 구성된다.
예를 들어 웹 서비스의 주문 데이터를 다음처럼 생각할 수 있다.
| order_id | user_id | category | amount |
|---|---|---|---|
| 1001 | 21 | Keyboard | 45000 |
| 1002 | 35 | Mouse | 29000 |
| 1003 | 21 | Monitor | 220000 |
여기서 각각의 열은 하나의 변수이고, 한 행은 하나의 관측값이라고 볼 수 있다.
Pandas를 사용할 때는 보통 다음처럼 불러온다.
import pandas as pd
2차원 리스트를 DataFrame으로 변환할 수 있다.
import pandas as pd
orders = [
[1001, "Keyboard", 45000],
[1002, "Mouse", 29000],
[1003, "Monitor", 220000],
]
df = pd.DataFrame(orders)
print(df)
열 이름을 지정하지 않으면 0, 1, 2처럼 정수형 열 이름이 만들어진다.
실제로 데이터를 다룰 때는 의미 있는 열 이름을 지정하는 편이 좋다.
df = pd.DataFrame(
orders,
columns=["order_id", "category", "amount"],
)
인덱스도 직접 지정할 수 있다.
df = pd.DataFrame(
orders,
index=["A001", "A002", "A003"],
columns=["order_id", "category", "amount"],
)
딕셔너리를 DataFrame으로 만들면 딕셔너리 Key가 열 이름이 된다.
orders = {
"order_id": [1001, 1002, 1003, 1004],
"category": ["Keyboard", "Mouse", "Monitor", "Mouse"],
"amount": [45000, 29000, 220000, 35000],
}
df = pd.DataFrame(orders)
인덱스를 함께 지정할 수도 있다.
df = pd.DataFrame(
orders,
index=["A001", "A002", "A003", "A004"],
)
작은 테스트 데이터를 직접 만들 때는 딕셔너리 방식이 읽기 편하다.
실제 분석에서는 직접 데이터를 만드는 것보다 외부 파일을 불러오는 경우가 많다.
CSV 파일은 pd.read_csv()로 읽는다.
df = pd.read_csv("orders.csv")
자주 사용하는 옵션은 다음과 같다.
| 옵션 | 의미 |
|---|---|
sep | 구분자 지정 |
header | 헤더로 사용할 행 지정 |
index_col | 인덱스로 사용할 열 지정 |
names | 열 이름 직접 지정 |
skiprows | 읽지 않을 행 지정 |
예를 들어 order_id 열을 인덱스로 사용한다면 다음처럼 작성할 수 있다.
df = pd.read_csv(
"orders.csv",
index_col="order_id",
)
인덱스 이름 자체를 제거하고 싶다면 다음처럼 처리할 수 있다.
df = (
pd.read_csv(
"orders.csv",
index_col="order_id",
)
.rename_axis(None)
)
DataFrame을 살펴볼 때는 메서드와 속성을 모두 사용한다.
메서드는 괄호 ()를 붙여 호출한다.
df.head()
df.info()
df.describe()
속성은 괄호 없이 사용한다.
df.shape
df.columns
df.dtypes
이 차이를 알고 있으면 TypeError, AttributeError 같은 단순 실수를 줄일 수 있다.
head()와 tail()상위 데이터를 빠르게 확인할 때는 head()를 사용한다.
df.head()
기본값은 5개 행이다.
df.head(10)
하위 데이터는 tail()로 확인한다.
df.tail(3)
파일을 처음 불러왔다면 실제 값이 예상한 형태인지 먼저 확인하는 습관이 중요하다.
shape, index, values, columns, dtypesDataFrame의 기본 구조는 속성으로 확인할 수 있다.
print(df.shape)
print(df.index)
print(df.values)
print(df.columns)
print(df.dtypes)
shaperows, columns = df.shape
(행 수, 열 수) 형태의 튜플을 반환한다.
columnsprint(df.columns)
열 이름을 확인한다.
dtypesprint(df.dtypes)
각 열이 숫자형인지 문자열 계열인지 등의 자료형을 확인할 수 있다.
info()로 전체 구조 확인하기df.info()
info()에서는 다음 정보를 한 번에 확인할 수 있다.
전체 행 수보다 특정 열의 non-null 개수가 적다면 해당 열에 결측치가 있다는 힌트를 얻을 수 있다.
describe()로 기술 통계 확인하기수치형 데이터의 분포를 빠르게 확인하려면 describe()를 사용한다.
df.describe()
대표적으로 다음 값이 나온다.
count: 유효한 값 개수mean: 평균std: 표준편차min: 최솟값25%: 1사분위수50%: 중앙값75%: 3사분위수max: 최댓값특정 열만 확인할 수도 있다.
df[["amount", "quantity"]].describe()
데이터의 최댓값, 최솟값, 평균, 중앙값과 분포를 한눈에 확인하려면
describe()를 먼저 떠올리면 된다.
sort_index()로 인덱스 정렬인덱스를 기준으로 정렬할 때는 sort_index()를 사용한다.
df.sort_index()
내림차순은 다음처럼 지정한다.
df.sort_index(ascending=False)
날짜나 순번처럼 의미 있는 값이 인덱스라면 유용하다.
sort_values()로 특정 열 정렬금액이 큰 주문부터 보고 싶다면 다음처럼 작성한다.
df.sort_values(
by="amount",
ascending=False,
)
여러 열을 기준으로 정렬할 수도 있다.
df.sort_values(
by=["category", "amount"],
ascending=[True, False],
)
위 코드는 카테고리를 오름차순으로 정렬하고, 같은 카테고리 안에서는 금액을 내림차순으로 정렬한다.
unique(), value_counts(), mode()범주형 데이터에 어떤 값들이 있는지 확인할 때는 unique()를 사용한다.
df["category"].unique()
각 값이 몇 번 등장하는지는 value_counts()로 확인한다.
df["category"].value_counts()
결측치를 포함해서 세고 싶다면 다음처럼 지정할 수 있다.
df["category"].value_counts(dropna=False)
최빈값은 mode()로 확인한다.
df["category"].mode()
대표적인 집계 메서드는 다음과 같다.
df["amount"].sum()
df["amount"].mean()
df["amount"].max()
df["amount"].min()
df["amount"].count()
여러 열을 한 번에 집계할 수도 있다.
df[["amount", "quantity"]].mean()
axis=0과 axis=1Pandas를 처음 배울 때 가장 헷갈리는 개념 중 하나가 axis다.
df.sum(axis=0)
axis=0은 행들을 따라 내려가면서 집계해서 열별 결과를 만든다. 기본값이다.
df.sum(axis=1)
axis=1은 열들을 가로질러 집계해서 행별 결과를 만든다.
간단하게 기억하면 다음과 같다.
axis=0 → 열별 결과
axis=1 → 행별 결과
drop()이나 dropna()에서도 같은 축 개념이 사용되지만, 그때는 각각 행 삭제와 열 삭제의 의미로 연결된다.
amount = df["amount"]
일반적으로 열 하나를 선택하면 Series가 반환된다.
print(type(amount))
DataFrame 형태를 유지하고 싶다면 리스트로 감싸서 선택한다.
amount_df = df[["amount"]]
df[["order_id", "category", "amount"]]
조회하고 싶은 열 이름을 리스트로 전달한다.
열의 순서를 바꾸어 전달하면 결과 열 순서도 그대로 바뀐다.
df[["amount", "category"]]
loc로 열 범위 조회loc는 라벨(label) 을 기준으로 데이터를 조회한다.
df.loc[:, "category":"amount"]
일반 Python 슬라이싱과 다르게 loc의 라벨 슬라이싱에서는 끝 라벨도 포함된다.
따라서 "category":"amount"라면 amount 열도 결과에 포함된다.
5만 원 이상인 주문만 조회해보자.
filtered = df.loc[
df["amount"] >= 50000
]
조건을 별도 변수에 저장해서 사용할 수도 있다.
condition = df["amount"] >= 50000
filtered = df.loc[condition]
조건이 복잡해지면 변수를 분리하는 방식이 읽기 쉽다.
&, |를 사용한다Pandas Series 조건에서는 Python의 and, or보다 &, |를 사용한다.
filtered = df.loc[
(df["amount"] >= 50000)
& (df["status"] == "paid")
]
OR 조건은 |를 사용한다.
filtered = df.loc[
(df["category"] == "Keyboard")
| (df["category"] == "Mouse")
]
각 조건을 괄호로 묶는 것이 중요하다.
isin()으로 여러 값 조회같은 열에 대해 여러 값을 OR 조건으로 조회한다면 isin()이 훨씬 읽기 쉽다.
target_categories = [
"Keyboard",
"Mouse",
]
filtered = df.loc[
df["category"].isin(target_categories)
]
다음 조건과 같은 의미다.
filtered = df.loc[
(df["category"] == "Keyboard")
| (df["category"] == "Mouse")
]
between()으로 범위 조회금액이 2만 원 이상 5만 원 이하인 주문만 조회해보자.
filtered = df.loc[
df["amount"].between(20000, 50000)
]
기본 설정에서는 양쪽 경계값이 모두 포함된다.
예를 들어 나이가 20세 이상 29세 이하인 행은 다음처럼 조회할 수 있다.
df.loc[
df["age"].between(20, 29)
]
괄호 () 안에 시작값과 끝값을 쉼표로 전달한다는 점을 기억하자.
필요하면 inclusive 옵션을 사용할 수 있다.
df["amount"].between(
20000,
50000,
inclusive="both",
)
사용 가능한 값은 다음과 같다.
"both": 양쪽 경계 포함"neither": 양쪽 경계 제외"left": 왼쪽만 포함"right": 오른쪽만 포함loc는 행 조건과 열 선택을 동시에 할 수 있다.
result = df.loc[
df["amount"] >= 50000,
["order_id", "category", "amount"],
]
형태를 기억하면 쉽다.
df.loc[행 조건, 열 선택]
날짜가 인덱스라면 loc로 범위를 조회할 수 있다.
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date").sort_index()
weekly = df.loc[
"2026-10-01":"2026-10-07"
]
loc의 라벨 슬라이싱은 일반적으로 시작 라벨과 끝 라벨을 모두 포함한다.
날짜 인덱스를 다룰 때 이 차이를 기억해야 한다.
reset_index()조건 조회 후 새로운 DataFrame을 만들면 기존 인덱스가 그대로 남을 수 있다.
filtered = df.loc[
df["amount"] >= 50000
]
기존 인덱스가 의미 없다면 다음처럼 초기화할 수 있다.
filtered = filtered.reset_index(
drop=True
)
원본 객체에 직접 반영하려면 다음처럼 사용할 수도 있다.
filtered.reset_index(
drop=True,
inplace=True,
)
drop=True를 지정하지 않으면 기존 인덱스가 일반 열로 추가된다.
groupby() 기본 구조카테고리별 주문 금액 합계를 구해보자.
summary = (
df.groupby(
"category",
as_index=False,
)[["amount"]]
.sum()
)
groupby()는 어떤 기준으로 묶을지 지정하고 뒤에 집계 메서드를 연결하는 방식으로 사용한다.
groupby(기준) → 집계할 열 선택 → sum(), mean(), max() ...
as_index가 결과 형태에 미치는 영향result = df.groupby(
"category",
as_index=True,
)["amount"].sum()
as_index=True이면 그룹 기준 열이 인덱스가 된다.
일반 열로 유지하고 싶다면 as_index=False를 사용할 수 있다.
result = df.groupby(
"category",
as_index=False,
)["amount"].sum()
같은 그룹화라도 열을 어떻게 선택하느냐에 따라 결과 형태가 달라질 수 있다.
result = df.groupby("category")["amount"].sum()
열 하나를 문자열로 선택하면 보통 Series 형태가 된다.
result = df.groupby("category")[["amount"]].sum()
리스트 형태로 선택하면 DataFrame 형태로 유지할 수 있다.
여러 열을 동시에 집계할 수 있다.
summary = (
df.groupby(
"category",
as_index=False,
)[["amount", "quantity"]]
.sum()
)
그룹 기준을 여러 개 지정할 수도 있다.
summary = (
df.groupby(
["category", "status"],
as_index=False,
)[["amount"]]
.sum()
)
집계 함수만 바꾸면 다양한 통계를 계산할 수 있다.
df.groupby("category")[["amount"]].mean()
df.groupby("category")[["amount"]].max()
df.groupby("category")[["amount"]].min()
df.groupby("category")[["amount"]].count()
rename()일부 열 이름만 변경할 때는 rename()을 사용한다.
df.rename(
columns={
"Name": "user_name",
"Level": "membership_level",
"Score": "score",
},
inplace=True,
)
여기서 중요한 부분은 매개변수 이름이 columns 라는 점이다.
다음처럼 반환값을 다시 저장하는 방식도 가능하다.
df = df.rename(
columns={"Name": "user_name"}
)
inplace=True를 사용하지 않았다면 반환된 DataFrame을 변수에 다시 대입해야 변경 결과를 유지할 수 있다.
모든 열 이름을 한 번에 바꿀 때는 columns 속성에 새 이름을 대입할 수 있다.
df.columns = [
"order_id",
"user_id",
"category",
"amount",
]
새 이름의 개수는 기존 열 개수와 같아야 한다.
존재하지 않는 열 이름에 값을 대입하면 맨 뒤에 새 열이 추가된다.
df["final_amount"] = (
df["amount"] + df["shipping_fee"]
)
기존 열을 계산해서 새로운 파생 변수를 만들 때 자주 사용한다.
insert()특정 위치에 열을 추가하려면 insert()를 사용한다.
df.insert(
2,
"unit_price",
df["amount"] / df["quantity"],
)
첫 번째 인자는 새 열을 삽입할 위치다.
drop()열 하나를 제거하려면 다음처럼 작성할 수 있다.
df.drop(
"unused_column",
axis=1,
inplace=True,
)
여러 열도 한 번에 삭제할 수 있다.
df.drop(
["temp_a", "temp_b"],
axis=1,
inplace=True,
)
axis 기준은 다음과 같다.
axis=0 → 행 방향
axis=1 → 열 방향
열 삭제 목적이라면 columns 인자를 사용하는 방식도 읽기 쉽다.
df = df.drop(
columns=["unused_column"]
)
map()으로 값 매핑범주형 데이터를 다른 값으로 바꾸고 싶다면 map()을 사용할 수 있다.
df["membership"] = df["membership"].map(
{
"Basic": 0,
"Premium": 1,
}
)
map()에서 매핑 딕셔너리에 없는 값은 NaN이 될 수 있다.
따라서 변환 전에 고윳값을 먼저 확인하면 안전하다.
df["membership"].value_counts(
dropna=False
)
replace()로 일부 값 치환replace()도 값 변경에 사용할 수 있다.
df["status"] = df["status"].replace(
{
"Y": "paid",
"N": "unpaid",
}
)
map()과 달리 매핑되지 않은 값은 원래 값을 유지하는 형태로 사용할 수 있어 일부 값만 선택적으로 바꾸는 데 편리하다.
info()로 결측치 힌트 찾기df.info()
전체 행 수보다 특정 열의 non-null 개수가 적다면 결측치가 존재한다.
다만 정확한 결측치 개수를 알고 싶다면 전용 메서드를 사용하는 편이 좋다.
isna()와 isnull()결측치이면 True, 값이 있으면 False를 반환한다.
df.isna()
열별 결측치 개수는 다음처럼 확인한다.
df.isna().sum()
isnull()도 같은 역할을 한다.
df.isnull().sum()
반대로 값이 존재하는지를 확인하려면 notna() 또는 notnull()을 사용할 수 있다.
df.notna()
dropna()로 결측치 제거결측치가 하나라도 포함된 행을 제거한다.
clean_df = df.dropna()
특정 열이 결측치인 행만 제거할 수도 있다.
clean_df = df.dropna(
subset=["amount"]
)
결측치가 있는 열을 제거하려면 axis=1을 사용한다.
clean_df = df.dropna(axis=1)
결측치가 있다는 이유만으로 무조건 행이나 열을 삭제하기보다 데이터 의미와 결측치 규모를 먼저 확인해야 한다.
fillna()로 값 채우기수치형 열의 결측치를 평균으로 채우는 예시다.
mean_amount = df["amount"].mean()
df["amount"] = df["amount"].fillna(
mean_amount
)
특정 값으로 채울 수도 있다.
df["discount"] = df["discount"].fillna(0)
어떤 값으로 채울지는 데이터의 의미와 분포에 따라 결정해야 한다.
직전 유효값으로 채우려면 ffill()을 사용한다.
df["price"] = df["price"].ffill()
다음 유효값으로 채우려면 bfill()을 사용한다.
df["price"] = df["price"].bfill()
시계열처럼 앞뒤 행의 순서가 의미 있는 데이터에서 사용할 수 있다.
interpolate()연속적인 수치 데이터라면 앞뒤 값을 이용해 중간값을 추정할 수 있다.
df["temperature"] = (
df["temperature"]
.interpolate(method="linear")
)
선형 변화로 보는 것이 적절한 데이터인지 먼저 판단한 뒤 사용해야 한다.
concat()과 merge()의 차이두 함수는 모두 DataFrame을 합칠 때 사용하지만 기준이 다르다.
| 기능 | 핵심 기준 | 대표 상황 |
|---|---|---|
pd.concat() | 축과 인덱스를 기준으로 이어 붙이기 | 같은 구조의 월별 데이터 연결 |
pd.merge() | 공통 Key를 기준으로 조인 | 사용자 테이블과 주문 테이블 결합 |
concat()으로 가로 연결서로 다른 열을 가진 DataFrame을 인덱스 기준으로 가로로 붙일 수 있다.
result = pd.concat(
[profile_df, activity_df],
axis=1,
join="outer",
)
axis=1은 열 방향 연결이다.
join="outer"는 인덱스 전체를 유지한다.
result = pd.concat(
[profile_df, activity_df],
axis=1,
join="inner",
)
join="inner"는 양쪽에 공통으로 존재하는 인덱스만 유지한다.
concat()으로 세로 연결같은 열 구조를 가진 DataFrame을 아래로 이어 붙일 수 있다.
all_logs = pd.concat(
[january_logs, february_logs],
axis=0,
)
axis=0은 행 방향 연결이며 기본값이다.
연결 후 인덱스를 새로 정리하고 싶다면 다음처럼 처리할 수 있다.
all_logs = all_logs.reset_index(
drop=True
)
merge() 기본 구조사용자 정보와 주문 정보가 서로 다른 DataFrame에 있다고 가정해보자.
users = pd.DataFrame(
{
"user_id": [1, 2, 3],
"name": ["A", "B", "C"],
}
)
orders = pd.DataFrame(
{
"order_id": [1001, 1002, 1003],
"user_id": [1, 1, 4],
"amount": [12000, 35000, 18000],
}
)
공통 Key인 user_id를 기준으로 결합한다.
result = pd.merge(
users,
orders,
on="user_id",
how="inner",
)
양쪽 DataFrame에 모두 존재하는 Key만 결과에 포함한다.
result = pd.merge(
users,
orders,
on="user_id",
how="inner",
)
how="inner"가 기본 조인 방식이다.
왼쪽 DataFrame의 모든 행을 유지한다.
result = pd.merge(
users,
orders,
on="user_id",
how="left",
)
오른쪽에 매칭되는 데이터가 없다면 해당 열에는 결측치가 생길 수 있다.
오른쪽 DataFrame의 모든 행을 유지한다.
result = pd.merge(
users,
orders,
on="user_id",
how="right",
)
왼쪽에서 매칭되지 않는 부분에는 결측치가 생길 수 있다.
양쪽 DataFrame의 모든 Key를 유지한다.
result = pd.merge(
users,
orders,
on="user_id",
how="outer",
)
한쪽에만 존재하는 Key는 다른 쪽 데이터가 없으므로 해당 부분이 결측치가 된다.
간단한 주문 데이터를 만들어보자.
import pandas as pd
orders = pd.DataFrame(
{
"order_id": [1001, 1002, 1003, 1004, 1005],
"category": [
"Keyboard",
"Mouse",
"Mouse",
"Monitor",
"Keyboard",
],
"amount": [
45000,
29000,
None,
220000,
78000,
],
"status": [
"paid",
"paid",
"cancelled",
"paid",
"paid",
],
}
)
orders.head()
orders.info()
orders.describe()
orders.isna().sum()
median_amount = orders["amount"].median()
orders["amount"] = orders["amount"].fillna(
median_amount
)
paid_orders = orders.loc[
orders["status"] == "paid"
]
target_orders = paid_orders.loc[
paid_orders["amount"].between(
30000,
100000,
)
]
summary = (
target_orders
.groupby(
"category",
as_index=False,
)[["amount"]]
.sum()
)
이 흐름을 정리하면 다음과 같다.
원본 데이터
↓
head / info / describe로 구조 확인
↓
필요한 행과 열 선택
↓
결측치와 이상한 값 정리
↓
새 열 추가 또는 값 변경
↓
groupby로 집계
↓
concat / merge로 다른 데이터와 결합
DataFrame을 익힐 때 가장 중요한 것은 개별 함수 이름보다 이 전체 흐름을 몸에 익히는 것이다.
df.rename(
columns={
"Name": "이름",
"Level": "수준",
"Score": "점수",
},
inplace=True,
)
columns에 기존 열 이름: 새 열 이름 형태의 딕셔너리를 전달한다.
df.loc[
df["age"].between(20, 29)
]
between은 메서드이므로 대괄호가 아니라 소괄호 () 를 사용한다.
df.describe()
최댓값, 최솟값, 평균, 중앙값, 사분위수 등을 빠르게 확인할 수 있다.
df.dropna()
A 기준 Inner Joindf = pd.merge(
df01,
df02,
on="A",
how="inner",
)
on에는 공통 Key 열 이름, how에는 조인 방식을 지정한다.
ColA 열 삭제df.drop(
"ColA",
axis=1,
inplace=True,
)
열 방향은 axis=1이다.
df.loc[
"2026-10-01":"2026-10-07"
]
loc의 라벨 슬라이싱은 끝 라벨까지 포함한다.
Series와 DataFrame 결과 구분df["amount"]
열 하나를 문자열로 선택하면 일반적으로 Series가 된다.
df[["amount"]]
리스트로 감싸면 DataFrame 형태를 유지한다.
pd.DataFrame() 또는 pd.read_csv()로 만들 수 있다.head(), tail(), shape, dtypes, info(), describe()로 구조와 분포를 먼저 확인하는 것이 좋다.sort_index()는 인덱스 정렬, sort_values()는 특정 열의 값을 기준으로 정렬할 때 사용한다.df["col"], 여러 열에는 df[["col1", "col2"]], 조건 조회에는 df.loc[조건]을 자주 사용한다.&, |로 연결하고 각각의 조건을 괄호로 묶으며, isin()과 between()을 이용하면 자주 사용하는 조건을 간결하게 표현할 수 있다.groupby() 뒤에 sum(), mean(), max(), min(), count() 등을 연결하면 그룹별 집계를 만들 수 있다.rename(), columns, 열 할당, insert(), drop()을 이용하면 DataFrame 구조를 변경할 수 있다.info(), isna() 등으로 확인하고 dropna(), fillna(), ffill(), bfill(), interpolate() 등으로 처리할 수 있다.pd.concat()은 축과 인덱스를 기준으로 연결하고, pd.merge()는 공통 Key를 기준으로 inner, left, right, outer 조인을 수행한다.