스키마 진화와 문서 마이그레이션
데이터 모델이 확장될 때 서비스 중단 없이 문서 스키마의 버전을 관리하고 기존 컬렉션을 마이그레이션합니다.
스키마 진화와 문서 마이그레이션은(는) CoddyKit의 무료 FastAPI Backend Development Bootcamp 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 FastAPI Backend Development Bootcamp 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. FastAPI Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
Why Schemas Evolve
In a FastAPI backend, your data model rarely stays frozen. New features mean new fields, renamed properties, and changed shapes. With a relational database you'd run an ALTER TABLE migration. MongoDB is schemaless at the storage layer, so nothing stops you from writing a new shape next to an old one.
- Old documents keep their original fields until you touch them.
- New documents follow the latest model.
- Your code must tolerate BOTH shapes during the transition.
This lesson shows how Beanie (the async ODM built on Motor + Pydantic) lets you version your documents and migrate collections without downtime.
The Mixed-Shape Problem
Imagine a Product document that started with a single price float. Later you split it into price_cents (int) plus currency. After deploy, your collection holds a mix:
- Old docs:
{ "price": 19.99 } - New docs:
{ "price_cents": 1999, "currency": "USD" }
If your Pydantic-backed model declares only the new fields as required, reading an old document raises a validation error. The core skill of schema evolution is making the model forgiving enough to load both, then upgrading data behind the scenes.
Track a Schema Version
The cleanest pattern is to stamp every document with a schema_version integer. Beanie documents are Pydantic models, so you add it as a field with a default. New writes get the current version automatically; old documents that lack the field fall back to version 1 because Pydantic applies the default when the key is missing.
This is the same idea as a plain Python class with a version attribute.
class ProductDoc:
CURRENT_VERSION = 2
def __init__(self, data):
self.schema_version = data.get("schema_version", 1)
self.data = data
def needs_migration(self):
return self.schema_version < self.CURRENT_VERSION
old = ProductDoc({"price": 19.99})
new = ProductDoc({"price_cents": 1999, "schema_version": 2})
print(old.schema_version, old.needs_migration())
print(new.schema_version, new.needs_migration())A Versioned Beanie Document
Here is the Beanie model. Note three things:
schema_versiondefaults to the current number for new documents.- The old
pricefield is kept as Optional so legacy docs still load. - New fields are also Optional so a half-migrated collection never crashes a read.
Keeping deprecated fields Optional instead of deleting them outright is the key to zero-downtime: code must read both shapes for the whole transition window.
from typing import Optional
from beanie import Document
class Product(Document):
schema_version: int = 2
name: str
# legacy (v1)
price: Optional[float] = None
# current (v2)
price_cents: Optional[int] = None
currency: Optional[str] = None
class Settings:
name = "products"Upgrade on Read (Lazy Migration)
The least disruptive strategy is lazy migration: when you load a document, detect the old version and transform it in memory, persisting the upgrade only if you happen to save. This spreads the work across normal traffic with no big batch job.
The pure transform logic is just a function. Test it in isolation before wiring it into Beanie.
def upgrade_product(doc: dict) -> dict:
version = doc.get("schema_version", 1)
if version < 2:
# v1 -> v2: float dollars to integer cents + currency
if doc.get("price") is not None:
doc["price_cents"] = round(doc["price"] * 100)
doc["currency"] = "USD"
doc.pop("price", None)
doc["schema_version"] = 2
return doc
print(upgrade_product({"name": "Pen", "price": 19.99}))
print(upgrade_product({"name": "Pad", "price_cents": 500,
"currency": "USD", "schema_version": 2}))Wiring Lazy Upgrade into FastAPI
In an endpoint, fetch the Beanie document, apply the upgrade if needed, and save it back. Because the upgrade is idempotent (already-v2 docs are untouched), it is safe to run on every read.
This is framework code that depends on Beanie and a running MongoDB, so treat the transform helper as the testable part and keep the I/O thin.
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/products/{product_id}")
async def get_product(product_id: str):
product = await Product.get(product_id)
if product is None:
raise HTTPException(status_code=404, detail="Not found")
if product.schema_version < 2 and product.price is not None:
product.price_cents = round(product.price * 100)
product.currency = "USD"
product.price = None
product.schema_version = 2
await product.save()
return productEager Migration with a Batch Script
Lazy migration leaves cold documents on the old shape forever. To fully retire field price, run an eager batch migration once: stream every old document, transform it, and write it back. Iterate over a query filter so you only touch unmigrated docs.
Always process in batches and use a filter like schema_version < 2 so a re-run resumes where it left off instead of redoing finished work.
async def migrate_products():
cursor = Product.find(Product.schema_version < 2)
migrated = 0
async for product in cursor:
if product.price is not None:
product.price_cents = round(product.price * 100)
product.currency = "USD"
product.price = None
product.schema_version = 2
await product.save()
migrated += 1
print(f"Migrated {migrated} products")Bulk Update for Speed
Saving documents one by one is fine for thousands of rows, but for millions you want the database to do the work. MongoDB's aggregation-pipeline update can compute the new field server-side in a single command, avoiding a round trip per document.
Beanie exposes this through update with a raw pipeline. Multiplying price by 100 and setting currency happens inside Mongo.
async def bulk_migrate_products():
await Product.find(Product.schema_version < 2).update(
[
{
"$set": {
"price_cents": {
"$round": [{"$multiply": ["$price", 100]}, 0]
},
"currency": "USD",
"schema_version": 2,
}
},
{"$unset": "price"},
]
)Beanie's Built-in Migrations
Beanie ships a migration framework so you don't hand-roll scripts. You write a migration module with a Forward (and optional Backward) class containing functions decorated with @iterative_migration(). Beanie records applied migrations in a migrations collection, just like Alembic does for SQL.
- Run forward:
beanie migrate -uri ... -db ... -p ./migrations - Each function receives the old and new document instances.
- State is tracked so migrations apply exactly once.
This gives you ordered, repeatable, version-controlled schema changes.
from beanie import Document, iterative_migration
class OldProduct(Document):
price: float
class Settings:
name = "products"
class NewProduct(Document):
price_cents: int
currency: str
class Settings:
name = "products"
class Forward:
@iterative_migration()
async def split_price(self, input_document: OldProduct,
output_document: NewProduct):
output_document.price_cents = round(input_document.price * 100)
output_document.currency = "USD"Renaming and Removing Fields Safely
Two changes look harmless but cause outages if rushed:
- Renaming a field: never rename in one step. Add the new field, dual-write both, backfill old docs, then drop the old field in a later release.
- Removing a field: deploy code that stops reading it first, then run a migration to
$unsetit.
The rule of thumb is the expand-and-contract pattern: expand the schema to support old and new at once, migrate the data, then contract by removing the old shape once no running code depends on it.
A Default-Backfill Helper
A common evolution is adding a brand-new field that older documents lack. For nullable convenience you give it an Optional default in the model, but to keep queries simple (e.g. filtering on is_active=True) you backfill a concrete default. This pure helper computes the patch dictionary you'd hand to MongoDB.
def backfill_defaults(doc: dict, defaults: dict) -> dict:
patch = {}
for key, value in defaults.items():
if key not in doc or doc[key] is None:
patch[key] = value
return patch
existing = {"name": "Widget", "price_cents": 1999}
defaults = {"is_active": True, "currency": "USD"}
print(backfill_defaults(existing, defaults))
# {'is_active': True, 'currency': 'USD'}Checkpoint: Choosing a Strategy
Test your understanding of zero-downtime migration order.
Recap
You learned how to evolve MongoDB document schemas with Beanie without taking the service down:
- Version documents with a
schema_versionfield defaulted in the Pydantic model. - Keep deprecated fields Optional so mixed-shape collections still load.
- Lazy migration upgrades documents on read; eager batch or bulk aggregation-pipeline updates fully retire old shapes.
- Use Beanie's
@iterative_migration()framework for ordered, tracked, version-controlled migrations. - Apply expand-and-contract: support old + new, migrate data, then remove the old shape only after no code depends on it.
These patterns let your data model grow alongside new features while users keep hitting the API uninterrupted.
자주 묻는 질문
“스키마 진화와 문서 마이그레이션” 강의는 무료인가요?
네 — “스키마 진화와 문서 마이그레이션” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 FastAPI Backend Development Bootcamp 강의 전체를 잠금 해제할 수 있습니다. FastAPI Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
“스키마 진화와 문서 마이그레이션”에서 뭘 배우나요?
데이터 모델이 확장될 때 서비스 중단 없이 문서 스키마의 버전을 관리하고 기존 컬렉션을 마이그레이션합니다. 브라우저에서 직접 실행하는 실습 코드로 FastAPI Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
FastAPI Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 FastAPI Backend Development Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.
“스키마 진화와 문서 마이그레이션” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 FastAPI Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 FastAPI Backend Development Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- Motor를 활용한 비동기 MongoDB 접근
- Beanie ODM을 활용한 문서 모델링
- 집계 파이프라인과 복잡한 쿼리
- 스키마 진화와 문서 마이그레이션