Datetime Naive MariaDB Bikin Waktu API Zonk
Commit db470da menambahkan _utc_aware di repository supaya timestamp artikel dari MariaDB DATETIME dikirim Pydantic dengan Z (UTC), bukan tanpa zona.
Deploy terakhir, saya buka halaman detail artikel dan nengok ke kolom waktu publish: 2026-08-29T14:30:00. Nggak ada huruf Z di ujungnya. Padahal server saya jalan di UTC, dan artikel itu memang saya publish jam 14.30 UTC. Masalahnya baru kelihatan pas ada pembaca di WIB (UTC+7) bilang waktunya "kok maju 7 jam".
Curiga pertama saya: salah format di frontend. Tapi pas saya buka raw response API-nya, timestamp-nya emang nggak punya offset sejak awal. Jadi bukan salah formatter, salahnya di satu lapisan lebih dalam.
Masalahnya ada di bacaan dari database
Kolom published_at di MariaDB bertipe DATETIME. Bedanya dengan TIMESTAMP, DATETIME nggak bawa informasi zona waktu sama sekali. Dokumentasi MySQL menyatakannya terang-terangan: TIMESTAMP dikonversi dari zona sesi ke UTC saat disimpan dan dikembalikan ke zona sesi saat dibaca, "tetapi ini tidak terjadi untuk tipe lain seperti DATETIME"[1]. Konvensi di repo ini simpan semuanya dalam UTC, jadi nilainya udah benar-benar UTC, cuma nggak ada labelnya.
Pas SQLAlchemy atau SQLModel baca baris itu, yang kembali adalah datetime naive (tzinfo=None). Pydantic lalu serialisasi datetime naive tanpa offset, jadi jadi 2026-08-29T14:30:00 polos. Di browser, new Date() nunggu string tanpa zona sebagai waktu lokal pengunjung:
// string tanpa zona -> dianggap waktu lokal viewer
new Date("2026-08-29T14:30:00");
// pembaca di WIB (UTC+7) membacanya sebagai 14.30 WIB
// padahal nilai aslinya 14.30 UTC -> selisih 7 jam
Singkatnya: API ngirim waktu UTC tapi nggak bilang "ini UTC", dan JavaScript dengan senang hati menganggapnya waktu setempat. Pembaca di luar zona server langsung lihat jam yang meleset.
Perbaiki di batas repository, bukan di model
Fix-nya kecil tapi ditaruh di tempat yang tepat: pas memetakan baris DB ke entity domain, di fungsi _utc_aware. Commit db470da nambahin ini di repository boundary:
def _utc_aware(dt: datetime | None) -> datetime | None:
"""Stamp naive DB datetimes as UTC."""
if dt is None:
return None
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
# dipakai saat map row -> domain entity
published_at=_utc_aware(row.published_at),
created_at=_utc_aware(row.created_at),
updated_at=_utc_aware(row.updated_at),
Seketika Pydantic lihat datetime yang aware-UTC dan serialisasi jadi 2026-08-29T14:30:00Z. Huruf Z itu penting: sekarang browser tahu ini UTC dan bisa konversi ke zona pengunjung dengan benar. Tempatnya saya pilih repository boundary, bukan di dalam model Pydantic, karena di situlah kontrak "yang dibaca dari DB adalah UTC" harus dijalankan. Entity domain jadi timezone-benar, dan ke-naive-an MariaDB nggak bocor ke lapisan atas. Pola batas serialisasi yang bocor kayak gini pernah saya tulis juga soal tipe TypeScript yang nggak ikut ke JSON.
Alasan tetap pakai DATETIME daripada TIMESTAMP
Saya sengaja tetap pakai DATETIME plus tag aware-UTC, bukan pindah ke TIMESTAMP. Alasannya: TIMESTAMP justru otomatis konversi ke dan dari zona sesi[1], berarti "zona mana yang dipakai" balik jadi bergantung setting session, persis ambiguitas yang mau saya hilangkan. Lagian TIMESTAMP punya batas rentang: MariaDB menyimpannya sebagai detik sejak epoch (1970-01-01 UTC)[2], jadi tanggal sangat lama atau sangat depan bisa jebol. Nyimpan UTC secara eksplisit di DATETIME lebih jelas dan hindari footgun era-2038.
Fix pendamping di frontend cuma nge-pin timeZone: "Asia/Jakarta" di Intl.DateTimeFormat[3] supaya SSR dan semua pengunjung lihat WIB. Tapi itu baru jalan benar karena API sekarang ngirim Z beneran, bukan karena frontend nebak.
Sumber
[1] https://dev.mysql.com/doc/en/datetime.html
[2] https://mariadb.com/docs/server/reference/data-types/date-and-time-data-types/timestamp