Setiap aplikasi yang saya deploy ke server punya satu masalah yang sama: konfigurasi. Password database, API key, URL Redis, semuanya beda antara laptop, staging, dan production. Cara cepatnya baca langsung lewat os.environ.get(...) — dan dua minggu kemudian kamu dapat KeyError di production karena satu variabel lupa di-set. pydantic-settings menyelesaikan ini dengan pola yang sama seperti Pydantic untuk validasi data: kamu tulis skemanya, library yang bekerja.
Masalah dengan os.environ
Coba lihat cara lama ini. Semua nilai jadi string, konversi tipe dilakukan manual, dan tidak ada yang menolak nilai yang salah sampai aplikasi benar-benar dipakai.
import os
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://localhost/app")
PORT = int(os.environ.get("PORT", "8000")) # "delapan" bikin ValueError di baris ini
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
print(DATABASE_URL, PORT, DEBUG)
Tiga baris itu terlihat tidak berbahaya sampai PORT diisi nilai non-numerik, atau kamu typo nama variabelnya — DATABSE_URL — dan dapat None yang diam-diam mengalir ke seluruh aplikasi. Lebih parah lagi, tidak ada satu tempat untuk melihat semua konfigurasi yang dibutuhkan. Kalau kamu bergabung ke proyek yang sudah jalan, kamu harus menebak variabel apa saja yang wajib di-set.
Install dan Settings Pertama
pydantic-settings butuh Pydantic v2 di belakangnya. Instal lewat pip atau uv.
pip install pydantic-settings
Sekarang definisikan skema. Field di kelas Settings otomatis dibaca dari environment variable dengan nama yang sama (case-insensitive), lalu dikonversi ke tipe yang kamu tulis.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
database_url: str
port: int = 8000
debug: bool = False
settings = Settings()
print(settings.database_url)
print(settings.port, type(settings.port).__name__)
print(settings.debug, type(settings.debug).__name__)
# .env
DATABASE_URL=postgresql://user:password@localhost:5432/app
PORT=9000
DEBUG=true
Jalankan dan kamu lihat port jadi integer 9000 dan debug jadi boolean True, bukan string. pydantic-settings membaca .env di direktori kerja, tapi environment variable yang benar-benar di-export di shell menang kalau ada bentrok. Nilai default di field (port: int = 8000) dipakai hanya kalau variabelnya tidak ditemukan sama sekali.
Urutan prioritasnya begini: environment variable di shell > nilai dari file .env > default di definisi field. Ini memudahkan override saat deploy tanpa mengubah satu baris kode pun.
Awalan env_prefix dan Model Bersarang
Proyek besar biasanya menamai semua variabelnya dengan awalan supaya tidak tabrakan, misalnya APP_PORT. Pakai env_prefix. Untuk konfigurasi yang banyak, pecah jadi model bersarang — pydantic-settings membaca nilai bersarang dengan __ sebagai pemisah.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Database(BaseSettings):
host: str
port: int = 5432
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_nested_delimiter="__")
app_name: str = "bengkelkode"
database: Database
allowed_hosts: list[str] = ["*"]
settings = Settings()
print(settings.app_name)
print(settings.database.host, settings.database.port)
print(settings.allowed_hosts)
# .env
APP_APP_NAME=api-bengkel
APP_DATABASE__HOST=db.internal
APP_DATABASE__PORT=5433
APP_ALLOWED_HOSTS=["api.example.com","api2.example.com"]
Perhatikan allowed_hosts bertipe list[str] — pydantic-settings mengurai string JSON [...] dari .env menjadi list Python sungguhan. Kalau isinya bukan JSON valid, aplikasi gagal sejak awal dengan pesan yang jelas, bukan error misterius jam dua pagi.
Rahasia dan Nilai yang Wajib Ada
API key dan password sebaiknya tidak pernah tercetak di log. Pydantic punya SecretStr yang menyembunyikan nilainya saat di-print atau di-repr. Kamu juga bisa menandai field wajib tanpa default, supaya aplikasi menolak jalan kalau kuncinya hilang.
from pydantic import SecretStr
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
api_key: SecretStr
model: str = "gpt-4o"
settings = Settings(_env_file=".env")
print(settings.api_key) # SecretStr('**********')
print(settings.api_key.get_secret_value()) # nilai asli, untuk dikirim ke API
print(settings.model_dump()) # api_key disembunyikan di sini juga
# .env
API_KEY=sk-1234567890abcdef
MODEL=gpt-4o
api_key tidak punya default, jadi kalau API_KEY tidak ada di .env, instansiasi Settings() langsung melempar ValidationError. Ini perilaku yang kamu inginkan: gagal cepat saat boot, bukan saat request ketiga belas masuk.
Pasang di FastAPI
Di aplikasi FastAPI, pola yang umum adalah satu objek settings global yang dimuat sekali lewat dependency. lru_cache memastikan parsing .env tidak diulang di setiap request.
from functools import lru_cache
from fastapi import FastAPI, Depends
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
api_token: str
max_upload_mb: int = 5
@lru_cache
def get_settings() -> Settings:
return Settings()
app = FastAPI()
@app.get("/config")
def show_config(settings: Settings = Depends(get_settings)):
return {"max_upload_mb": settings.max_upload_mb}
Keuntungannya bukan cuma soal performa. Karena Settings adalah satu objek biasa, kamu bisa menukar nilainya di test tanpa menyentuh environment asli — cukup buat instance baru dengan nilai berbeda, atau override lewat parameter Settings(max_upload_mb=100).
Jangan pernah commit .env berisi kredensial production ke repo atau ke image Docker. Set nilai lewat environment variable di sistem orchestrator — Docker --env-file, Kubernetes Secret, atau CI/CD secret — dan pakai .env hanya untuk development lokal.
Ganti Konfigurasi per Environment
Tim yang serius jarang punya satu .env saja. Kamu bisa menentukan file mana yang dibaca lewat environment variable ENV, lalu memilih file yang sesuai saat membuat objek settings.
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str
debug: bool = False
def load_settings() -> Settings:
env = os.environ.get("ENV", "development")
return Settings(_env_file=f".env.{env}")
settings = load_settings()
print(settings.database_url, settings.debug)
Dengan ENV=production, kamu baca .env.production; tanpa variabel itu, defaultnya .env.development. Atribut extra="ignore" membuat variabel tak dikenal di environment diabaikan saja, bukan bikin error — berguna kalau deployment-mu menyuntik banyak variabel lain ke container yang sama.
Mulai dari satu hal kecil: pindahkan tiga atau empat konfigurasi yang paling sering berubah ke satu kelas Settings, lalu hapus semua pemanggilan os.environ yang tersebar. Kalau kamu sudah terbiasa menulis type hints, membaca tipe field Settings terasa seperti dokumentasi yang selalu akurat, karena memang itulah yang terjadi.