Kamu menulis API dengan FastAPI dan tiba-tiba sadar: model yang sama kamu ketik tiga kali. Satu class Pydantic buat validasi request, satu buat response, satu lagi class SQLAlchemy buat tabel database. Tiga definisi dengan field nyaris identik, dan setiap kali ada kolom baru kamu harus mengedit semuanya. Lupa satu saja, request lolos validasi tapi gagal masuk database. SQLModel menutup celah itu. Kamu cukup mendefinisikan satu class, lalu dipakai untuk validasi Pydantic sekaligus tabel SQL.
Dari Mana SQLModel Berasal
SQLModel dibuat oleh Sebastián Ramírez, orang yang sama di balik FastAPI. Di bawah kapnya ada dua library yang mungkin sudah kamu kenal: SQLAlchemy untuk layer database dan Pydantic untuk validasi data. SQLModel menempelkan keduanya jadi satu API, jadi kamu tidak bolak-balik antara dua sintaks yang berbeda.
Efeknya paling terasa di FastAPI. Tanpa SQLModel, kamu menulis class Pydantic untuk request dan response, lalu class SQLAlchemy terpisah untuk tabel. Dengan SQLModel, satu class menangani keduanya, dan FastAPI membaca type hints dari class itu untuk membangun dokumentasi OpenAPI secara otomatis.
Instalasi
Satu paket sudah membawa semua yang dibutuhkan. Tapi untuk tutorial ini kamu juga butuh FastAPI dan server ASGI untuk menjalankannya.
pip install sqlmodel fastapi uvicorn
Kalau kamu memakai uv, tinggal ganti awalan perintahnya jadi uv pip install sqlmodel fastapi uvicorn. Semua contoh di bawah berjalan dengan SQLModel versi stabil terbaru.
Model Pertama: Satu Class, Dua Peran
Mulai dari model sederhana untuk menyimpan data hero fiksi. Perhatikan, ini satu-satunya class yang perlu kamu tulis untuk tabel sekaligus validasi.
from typing import Optional
from sqlmodel import Field, SQLModel
class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: Optional[int] = Field(default=None, index=True)
Parameter table=True memberi tahu SQLModel bahwa class ini juga merepresentasikan tabel database, bukan sekadar skema validasi. Field id bertipe Optional[int] dengan primary_key=True — nilainya diisi database saat data pertama kali disimpan, jadi boleh None sebelum disimpan.
Field opsional ditulis Optional[int] dengan default=None. Jangan tulis int polos lalu kasih default=None, karena Pydantic akan menolaknya — tipe dan default tidak konsisten. Konsisten memakai Optional[...] untuk semua field yang boleh kosong.
Membuat Database dan Tabel
SQLModel memakai engine SQLAlchemy untuk terhubung ke database. Di tutorial ini kita pakai SQLite supaya tidak perlu setup server tambahan.
from sqlmodel import SQLModel, create_engine
sqlite_url = "sqlite:///heroes.db"
engine = create_engine(sqlite_url, echo=True)
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
echo=True membuat SQLAlchemy mencetak setiap query SQL ke terminal — berguna sekali saat debugging. SQLModel.metadata.create_all(engine) lalu membuat semua tabel dari class yang kamu tandai table=True.
Operasi CRUD Dasar
Sebelum nyambung ke FastAPI, latih dulu operasi dasarnya lewat fungsi biasa. Ini membantu kamu paham cara kerja Session SQLModel.
from sqlmodel import Session, select
def create_hero(hero: Hero) -> Hero:
with Session(engine) as session:
session.add(hero)
session.commit()
session.refresh(hero)
return hero
def read_heroes() -> list[Hero]:
with Session(engine) as session:
return list(session.exec(select(Hero)).all())
def read_hero(hero_id: int) -> Hero | None:
with Session(engine) as session:
return session.get(Hero, hero_id)
def update_hero(hero_id: int, data: dict) -> Hero | None:
with Session(engine) as session:
hero = session.get(Hero, hero_id)
if hero is None:
return None
for key, value in data.items():
setattr(hero, key, value)
session.add(hero)
session.commit()
session.refresh(hero)
return hero
def delete_hero(hero_id: int) -> bool:
with Session(engine) as session:
hero = session.get(Hero, hero_id)
if hero is None:
return False
session.delete(hero)
session.commit()
return True
Session adalah pintu masuk ke transaksi database. Polanya selalu sama: buka session, ubah data, panggil commit() untuk menyimpan. Kalau kamu sudah terbiasa dengan SQLAlchemy, sintaks ini terasa familiar. Bedanya, objek yang kamu pegang adalah model Pydantic, jadi hasilnya langsung punya validasi dan serialisasi bawaan.
Menyambungkan ke FastAPI
Bagian ini intinya. FastAPI membaca class SQLModel langsung sebagai response_model dan skema request, tanpa konfigurasi tambahan.
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, HTTPException
from sqlmodel import Field, Session, SQLModel, create_engine, select
class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: Optional[int] = Field(default=None, index=True)
class HeroCreate(SQLModel):
name: str
secret_name: str
age: Optional[int] = None
class HeroRead(SQLModel):
id: int
name: str
secret_name: str
age: Optional[int] = None
class HeroUpdate(SQLModel):
name: Optional[str] = None
secret_name: Optional[str] = None
age: Optional[int] = None
engine = create_engine("sqlite:///heroes.db")
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(lifespan=lifespan)
@app.post("/heroes/", response_model=HeroRead)
def create_hero(hero: HeroCreate) -> Hero:
with Session(engine) as session:
db_hero = Hero.model_validate(hero)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero
@app.get("/heroes/", response_model=list[HeroRead])
def read_heroes() -> list[Hero]:
with Session(engine) as session:
return list(session.exec(select(Hero)).all())
@app.get("/heroes/{hero_id}", response_model=HeroRead)
def read_hero(hero_id: int) -> Hero:
with Session(engine) as session:
hero = session.get(Hero, hero_id)
if hero is None:
raise HTTPException(status_code=404, detail="Hero tidak ditemukan")
return hero
@app.patch("/heroes/{hero_id}", response_model=HeroRead)
def update_hero(hero_id: int, hero: HeroUpdate) -> Hero:
with Session(engine) as session:
db_hero = session.get(Hero, hero_id)
if db_hero is None:
raise HTTPException(status_code=404, detail="Hero tidak ditemukan")
hero_data = hero.model_dump(exclude_unset=True)
db_hero.sqlmodel_update(hero_data)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero
@app.delete("/heroes/{hero_id}")
def delete_hero(hero_id: int) -> dict:
with Session(engine) as session:
hero = session.get(Hero, hero_id)
if hero is None:
raise HTTPException(status_code=404, detail="Hero tidak ditemukan")
session.delete(hero)
session.commit()
return {"ok": True}
Perhatikan peran tiap class. Hero adalah tabel sekaligus model. HeroCreate hanya untuk data masuk, HeroRead untuk data keluar, HeroUpdate untuk pembaruan parsial. Pemisahan ini penting: kamu tidak ingin client mengirim id seenaknya saat membuat data baru.
hero.model_dump(exclude_unset=True) hanya mengambil field yang benar-benar dikirim client, lalu sqlmodel_update() menuliskannya ke objek database. Kombinasi ini membuat endpoint PATCH aman untuk pembaruan sebagian tanpa menimpa field lain dengan None.
Jalankan server dengan uvicorn main:app --reload, lalu buka http://127.0.0.1:8000/docs. FastAPI sudah menyiapkan UI interaktif di sana — kamu bisa mencoba POST ke /heroes/ langsung dari browser tanpa menulis client.
Relationship Antar Tabel
Data dunia nyata jarang berdiri sendiri. Mari tambahkan relasi satu-ke-banyak antara tim dan anggotanya.
from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel
class Team(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
heroes: List["Hero"] = Relationship(back_populates="team")
class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: Optional[int] = Field(default=None, index=True)
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
team: Optional[Team] = Relationship(back_populates="heroes")
foreign_key="team.id" mendefinisikan kolom penghubung, sementara Relationship memudahkan navigasi dari dua arah. Sekarang kamu bisa membaca tim beserta semua anggotanya tanpa menulis join manual.
def read_team_with_heroes(team_id: int) -> Team | None:
with Session(engine) as session:
return session.get(Team, team_id) # team.heroes terisi otomatis lewat Relationship
Ketika skema berubah di tengah proyek yang sudah berjalan, create_all tidak cukup — kamu butuh migration. Alembic adalah pasangan standar untuk itu dan bekerja langsung dengan metadata SQLModel.
Sebagai latihan, tambahkan sendiri endpoint GET /teams/ yang mengembalikan daftar tim beserta heroes-nya. Kalau kamu sudah terbiasa menulis FastAPI dan SQLAlchemy secara terpisah, kamu akan cepat merasakan bedanya: lebih sedikit class, lebih sedikit tempat untuk lupa memperbarui field.