FastAPI 코드의 절반은 Pydantic이다. 이 편은 FastAPI를 잠시 내려놓고 Pydantic만 떼어 파이썬 코드로 익힌다. 모델을 만들고, 틀린 값에서 나오는 ValidationError를 읽고, 필수·선택·null의 네 가지 조합, 기본(lax) 모드의 타입 변환 규칙과 strict 모드, Field 제약, 중첩 모델, field_validator·model_validator, model_dump·model_validate, camelCase 별칭까지 — 전부 실제 실행 결과로 보여 준다. 필드 타입별로 값이 어떻게 바뀌는지 lax와 strict를 나란히 비교하는 위젯을 포함한다.
/docs의 Schemas 칸, Try it out의 예시 값, 틀린 요청에 돌아오던 422 에러 — 전부 이 몇 줄에서 나왔다. 이것이 Pydantic이다. FastAPI는 요청을 받고 응답을 보내는 뼈대이고, 데이터를 검사하고 변환하는 일은 거의 전부 Pydantic이 한다. 그래서 FastAPI를 “Starlette + Pydantic”이라고 부르기도 한다(1편 5절).
다음 편부터 CRUD를 만들며 Pydantic 모델을 잔뜩 쓴다. 그 전에 이 편에서는 FastAPI 없이 Pydantic만 떼어 파이썬 코드로 익힌다. 서버도 브라우저도 필요 없다. 파이썬 파일 하나나 대화형 셸(python3)이면 충분하다. fastapi[standard]를 설치했다면 Pydantic도 이미 들어 있다.
필드 세 개에 열다섯 줄이다. 게다가 첫 번째 에러에서 멈춰서, 두 곳이 틀려도 하나만 알려 준다. 필드가 스무 개인 API가 서른 개라면? Pydantic으로 같은 일을 하면 이렇다.
python
from pydantic import BaseModel, Field
classUser(BaseModel):
name: str
age: int = Field(ge=0)
email: str | None = None
데이터의 모양을 클래스로 선언하면, 검사·변환·에러 메시지는 Pydantic이 만든다. 이 클래스를 모델이라고 부른다. 설계도라고 생각하면 쉽다. 설계도(모델)를 주면 Pydantic이 들어온 재료(딕셔너리)를 검사해서, 맞으면 반듯한 객체로 만들어 주고 틀리면 무엇이 왜 틀렸는지 전부 적어 돌려준다.
2. 첫 모델 만들기
python
from pydantic import BaseModel
classUser(BaseModel):
name: str
age: int
email: str | None = None
u = User(name="김철수", age="30")
print(repr(u))
print(u.name, u.age, type(u.age).__name__)
실제 출력이다.
text
User(name='김철수', age=30, email=None)
김철수 30 int
세 가지를 확인할 수 있다.
BaseModel을 상속하고, 필드를 이름: 타입으로 적는다. 파이썬 타입 힌트 문법 그대로다.
age="30"(문자열)을 넣었는데 30(정수)이 됐다. Pydantic은 기본적으로 “뜻이 분명하면 바꿔 준다”. 이 규칙은 5절에서 자세히 본다.
email을 안 넣었더니 None이 됐다. 기본값을 준 필드는 생략할 수 있다.
만든 객체는 평범한 파이썬 객체처럼 u.name, u.age로 쓴다. 에디터가 필드 이름을 자동완성해 주고, u.nmae 같은 오타는 에디터와 타입 검사기가 잡아 준다. 딕셔너리의 data["nmae"]는 실행해 봐야 안다.
3. 틀리면 — ValidationError 읽기
python
from pydantic import ValidationError
try:
User(name="김철수", age="서른")
except ValidationError as e:
print(e)
text
1 validation error for User
age
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='서른', input_type=str]
For further information visit https://errors.pydantic.dev/2.13/v/int_parsing
틀린 값이 들어오면 Pydantic은 객체를 만들지 않고 ValidationError 를 던진다. 읽는 법은 위에서 아래로.
몇 개·어느 모델
1 validation error for User — User 모델에서 에러 1개.
어느 필드
age
무엇이 왜
Input should be a valid integer..., 종류는 type=int_parsing, 들어온 값은 '서른'.
더 알아보기
에러 종류마다 공식 설명 페이지 주소가 붙는다.
e.errors()를 부르면 같은 내용이 리스트로 나온다. 원소 하나하나에 type, loc, msg, input이 들어 있다. 어디서 본 모양 아닌가? 2편에서 읽는 법을 외운 FastAPI의 422 응답 detail이 바로 이 리스트다. FastAPI는 Pydantic의 ValidationError를 받아서 위치(loc) 앞에 "body"·"query"·"path"를 붙여 JSON으로 돌려줄 뿐이다. Pydantic 에러를 읽을 줄 알면 FastAPI 에러도 읽을 수 있다.
4. 필수·선택·null — 네 가지 조합
처음 배울 때 가장 헷갈리는 부분이다. 필드는 두 가지 질문으로 나뉜다. “안 보내도 되나?”(기본값이 있나)와 “null(None)이어도 되나?”(타입에 | None이 있나). 둘은 서로 다른 질문이다.
선언
안 보내면
None을 보내면
언제 쓰나
name: str
에러 (missing)
에러 (string_type)
반드시 있어야 하는 값 — 제목, 이메일
name: str = "무명"
기본값 "무명"
에러
생략하면 정해진 값 — 우선순위 3
name: str | None = None
None
None
없어도 되는 값 — 메모, 설명
name: str | None
에러 (missing)
None
드물다 — “비어 있음”을 명시적으로 보내게 할 때
마지막 줄이 함정이다. | None을 붙이면 “선택”이 된다고 착각하기 쉽지만, 기본값이 없으면 여전히 필수다. 직접 확인해 보자.
외우는 법:= 기본값은 “안 보내도 된다”, | None은 “비어 있어도 된다”. 선택 필드를 만들 때는 거의 항상 둘을 같이 쓴다 — str | None = None. 6편의 PATCH 모델이 전부 이 모양이고, 그 편에서 이 두 질문이 섞일 때 생기는 버그(PATCH로 null이 들어가는 문제)를 다룬다.
5. 타입 변환 규칙 — lax 모드와 strict 모드
2절에서 "30"이 30이 됐다. Pydantic의 기본 모드는 lax(너그러운) 모드다. “이 값을 이 타입으로 바꿔도 뜻이 분명한가?”를 보고 분명하면 바꿔 준다. 그런데 그 경계가 직관과 다를 때가 있다. 아래 위젯에서 필드 타입을 골라 JSON 값 15가지가 어떻게 되는지 보자. 모든 결과는 실제 Pydantic을 돌린 값이다.
위젯에서 꼭 확인할 것 다섯 가지:
타입
받아 준다 (lax)
거절한다
놀라운 점
int
"3", 3.0
3.5(깎지 않음), "abc"
true가 1이 된다
str
문자열만
숫자 3도 거절
숫자 쪽과 반대로 엄격. 빈 문자열 ""은 통과
bool
true, "yes", "on", "1"
3, "abc"
문자열 “yes”가 True
datetime
ISO 문자열, 날짜만(자정으로)
"abc"
"3"이 1970-01-01 00:00:03 — 숫자를 유닉스 시간으로 읽는다
list[int]
[1, "2"] → [1, 2]
"1,2"
원소도 하나씩 변환한다
lax 모드는 편하다. 특히 경로·쿼리 매개변수는 원래 전부 문자열로 오기 때문에(2편), 변환이 없으면 /users/42조차 쓸 수 없다. 하지만 “true가 1이 된다”처럼 원치 않는 변환이 문제라면 strict 모드를 켠다.
python
from pydantic import BaseModel, ConfigDict
classPayment(BaseModel):
model_config = ConfigDict(strict=True) # 이 모델 전체를 엄격하게
amount: int
Payment(amount="30") # ValidationError — type: int_type, "Input should be a valid integer"
Payment(amount=30) # 통과
필드 하나만 엄격하게 하려면 amount: int = Field(strict=True)로 쓴다. 금액·수량처럼 잘못 바뀌면 사고가 나는 값에만 거는 것이 보통이다. 요청 본문 전체를 strict로 바꾸면 “숫자를 문자열로 보내는” 구버전 클라이언트가 한꺼번에 깨질 수 있으니 팀에서 정하고 쓰자.
6. Field — 규칙과 설명 더하기
타입만으로 부족한 규칙은 Field(...)로 더한다. 2편의 Query(ge=1, le=100)과 같은 이름을 쓴다.
인자
대상
뜻
예
default
모두
기본값 (없으면 필수)
Field(default=3)
min_length / max_length
문자열·리스트
길이 범위
Field(min_length=1, max_length=100)
pattern
문자열
정규식에 맞아야 함
Field(pattern=r"^\d{3}-\d{4}$")
ge / gt
숫자
이상(≥) / 초과(>)
Field(ge=0)
le / lt
숫자
이하(≤) / 미만(<)
Field(le=5)
multiple_of
숫자
배수
Field(multiple_of=100) — 100원 단위
description / examples
모두
문서용 설명과 예시 — 검사에는 영향 없음
3편의 /docs에 그대로 나온다
alias
모두
JSON에서 쓰는 다른 이름
11절
회원 가입 예시다.
python
from pydantic import BaseModel, EmailStr, Field
classSignup(BaseModel):
email: EmailStr
nickname: str = Field(min_length=2, max_length=10, pattern=r"^[가-힣a-zA-Z0-9]+$")
Signup(email="a@b.com", nickname="코어닷") # 통과
Signup(email="not-email", nickname="코어 닷!") # 에러 2개
두 번째 줄의 실제 에러(요약):
loc
type
msg
email
value_error
value is not a valid email address: An email address must have an @-sign.
nickname
string_pattern_mismatch
String should match pattern '^[가-힣a-zA-Z0-9]+$'
틀린 곳 두 개가 한 번에 나왔다. 1절에서 직접 쓴 검사 함수는 첫 번째에서 멈췄다. 폼 화면이라면 이 차이가 크다 — 사용자가 “제출 → 에러 → 고침 → 제출 → 또 에러”를 반복하지 않는다.
7. 자주 쓰는 타입들
타입
받는 값
틀리면 (type)
str, int, float, bool
기본 값들 (변환 규칙은 5절)
string_type, int_parsing …
date, datetime
"2026-09-27", "2026-09-27T10:30:00"
date_parsing, datetime_parsing
Literal["blog", "news"]
나열한 값 중 하나
literal_error
Enum 클래스
Enum의 값 중 하나 (2편의 Category)
enum
EmailStr
이메일 형식 문자열 (email-validator 필요 — fastapi[standard]에 포함)
value_error
HttpUrl
"https://core.today/blog" — "core.today"처럼 스킴이 없으면 거절
url_parsing
UUID
UUID 문자열
uuid_parsing
list[X], dict[str, X]
배열·객체 (원소도 X로 검사)
list_type + 원소의 에러
다른 BaseModel
중첩 객체 (8절)
안쪽 필드의 에러
8. 모델 안에 모델 — 중첩
실제 데이터는 대개 겹겹이다. 주문 하나에 상품이 여러 개 들어 있다. 모델의 필드 타입으로 다른 모델을 쓰면 된다.
python
classItem(BaseModel):
name: str
price: int = Field(ge=0)
classOrder(BaseModel):
order_id: int
items: list[Item]
memo: str | None = None
order = Order(order_id=2, items=[{"name": "사과", "price": 1000}])
print(order.items[0].name, type(order.items[0]).__name__) # 사과 Item
딕셔너리로 넣었는데 order.items[0]은 Item 객체가 됐다. 안쪽까지 전부 검사하고 변환한다. 두 번째 상품의 가격이 음수면 에러의 위치가 이렇게 나온다(실제 출력).
u.model_copy(update={"age": 31}) → age만 31, 원본 u는 30 그대로
model_dump()에는 자주 쓰는 옵션이 있다.
exclude_unset=True — 실제로 들어온 필드만 꺼낸다. 6편 PATCH의 핵심.
exclude_none=True — 값이 None인 필드를 뺀다.
mode="json" — datetime을 문자열로 바꾸는 등 JSON에 넣을 수 있는 값으로 꺼낸다.
by_alias=True — 11절의 별칭 이름으로 꺼낸다.
⚠️
검사는 “만들 때”만 한다. 이미 만든 객체의 필드에 나중에 값을 넣으면(u.age = "abc") 기본 설정에서는 검사하지 않고 그대로 들어간다(실제로 'abc'가 들어갔다). model_copy(update=...)도 마찬가지다. 대입할 때도 검사하려면 모델 설정(model_config)에서 validate_assignment=True를 켠다 — 그러면 같은 대입이 ValidationError를 낸다. 6편에서 이 성질 때문에 생기는 버그를 직접 본다.
11. 파이썬은 snake_case, 프론트엔드는 camelCase
파이썬은 created_at, 자바스크립트는 createdAt을 쓴다. 한쪽이 양보해야 하는데, Pydantic의 별칭(alias) 을 쓰면 코드는 파이썬답게 두고 JSON만 camelCase로 주고받을 수 있다.
python
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
classTaskOut(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
task_id: int
created_at: datetime
t = TaskOut.model_validate({"taskId": 1, "createdAt": "2026-09-27T10:00:00"})
t.model_dump() # {'task_id': 1, 'created_at': datetime(...)}
t.model_dump(by_alias=True, mode="json") # {'taskId': 1, 'createdAt': '2026-09-27T10:00:00'}
alias_generator=to_camel — 모든 필드에 camelCase 별칭을 자동으로 붙인다.
populate_by_name=True — 파이썬 코드에서는 원래 이름(TaskOut(task_id=2, ...))으로도 만들 수 있게 한다.
FastAPI에서 이 모델을 response_model로 쓰면 응답이 자동으로 별칭 이름({"taskId": 1, "createdAt": ...})으로 나간다(실제 확인).
어느 쪽으로 통일할지는 7편의 팀 규칙 표에서 정한다. 중요한 건 API마다 섞이지 않게 하는 것이다.
12. 알아 두면 편한 성질 두 가지
① 리스트 기본값을 그냥 써도 된다. 일반 파이썬 함수에서 def f(tags=[])는 모든 호출이 같은 리스트를 공유하는 유명한 함정이다. Pydantic 모델은 객체마다 새로 복사한다.
python
classBox(BaseModel):
tags: list[str] = []
a, b = Box(), Box()
a.tags.append("x")
print(a.tags, b.tags) # ['x'] [] — b는 영향 없음
② 모델은 스스로를 설명한다.User.model_json_schema()를 부르면 모델이 JSON Schema로 나온다(실제 출력에서 발췌).