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.

bash
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.

python
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.

python
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.

python
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.

python
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.

python
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.