Endpoint yang hanya menerima JSON tidak akan pernah bisa menerima foto profil. Begitu client mengirim file lewat form HTML, isi request berubah jadi multipart/form-data, dan tanpa penanganan khusus FastAPI langsung membalas 422. Upload file memang tampak sepele — baca UploadFile, simpan, selesai — tapi di lapangan ada tiga jebakan yang sering bikin orang tersandung: lupa install python-multipart, file besar yang menguras RAM karena dibaca sekaligus, dan nama file asli yang kalau dipakai mentah-mentah bisa ditimpa atau jadi celah. Kalau kamu belum pernah menyentuh dasar-dasar framework ini, baca dulu panduan FastAPI dari nol sebelum lanjut.

Siapkan project dan dependensinya

Buat virtualenv, lalu install tiga paket. Dua yang pertama sudah jelas, tapi python-multipart sering terlewat karena FastAPI tidak menariknya sebagai dependensi otomatis. Tanpa paket ini, setiap request multipart/form-data gagal dengan pesan Form data requires "python-multipart" to be installed.

pip install fastapi "uvicorn[standard]" python-multipart

Endpoint upload paling dasar

Deklarasikan parameter bertipe UploadFile, dan FastAPI akan mem-parsing file yang dikirim client. Di balik layar, UploadFile adalah wrapper di atas SpooledTemporaryFile milik Starlette: file kecil disimpan di memori, file besar otomatis dipindah ke disk. Itulah kenapa kamu bisa menangani upload besar tanpa khawatir RAM langsung jebol.

from fastapi import FastAPI, UploadFile

app = FastAPI()


@app.post("/upload/")
async def upload_file(file: UploadFile):
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "size": file.size,
    }

Jalankan server dengan uvicorn main:app --reload, lalu kirim file pakai curl. Perhatikan bahwa di curl, -F membuat request menjadi multipart/form-data — jangan pakai -d, itu untuk application/x-www-form-urlencoded.

bash
curl -F "file=@laporan.txt" http://127.0.0.1:8000/upload/

Kalau client kamu JavaScript, bentuk body-nya dengan FormData dan biarkan browser mengatur header Content-Type sendiri. Jangan set Content-Type: multipart/form-data secara manual — boundary-nya harus di-generate otomatis, dan kalau kamu set manual, server tidak bisa mem-parsing isi body.

Simpan file ke disk

Mengembalikan metadata saja tidak banyak gunanya. Untuk menyimpan file, salin isi UploadFile ke file baru. Gunakan shutil.copyfileobj supaya penyalinan berjalan per-chunk dan tidak memuat seluruh file ke memori sekaligus. Folder uploads saya buat otomatis dengan pathlib.

python
import shutil
from pathlib import Path

from fastapi import FastAPI, UploadFile

app = FastAPI()

UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)


@app.post("/upload/")
async def upload_file(file: UploadFile):
    destination = UPLOAD_DIR / file.filename
    with destination.open("wb") as buffer:
        shutil.copyfileobj(file.file, buffer)
    return {"filename": file.filename, "size": destination.stat().st_size}

Upload banyak file sekaligus

Untuk menerima beberapa file dalam satu request, ubah tipe parameternya jadi list[UploadFile]. Client tinggal mengirim field yang sama berulang kali.

python
from fastapi import FastAPI, UploadFile

app = FastAPI()


@app.post("/upload/multiple/")
async def upload_multiple(files: list[UploadFile]):
    names = []
    for file in files:
        content = await file.read()
        with open(f"uploads/{file.filename}", "wb") as buffer:
            buffer.write(content)
        names.append(file.filename)
    return {"uploaded": names}

Batasi ukuran dan jenis file

Menerima upload tanpa filter sama saja mengundang masalah. Client bisa mengirim file 2 GB yang mengisi disk server, atau file dengan nama seperti ../../etc/passwd yang mengarah keluar folder uploads. Dua hal yang harus selalu kamu validasi: tipe file lewat content_type, dan ukuran file sebelum menyimpannya. Soal mengamankan aplikasi Python secara umum — SQL injection, XSS, dan kawan-kawannya — sudah saya bahas di artikel keamanan web Python.

python
from pathlib import Path

from fastapi import FastAPI, HTTPException, UploadFile

app = FastAPI()

UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

MAX_SIZE = 2 * 1024 * 1024  # 2 MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "text/plain", "application/pdf"}


@app.post("/upload/")
async def upload_file(file: UploadFile):
    if file.content_type not in ALLOWED_TYPES:
        raise HTTPException(status_code=415, detail="Tipe file tidak diizinkan")

    content = await file.read()
    if len(content) > MAX_SIZE:
        raise HTTPException(status_code=413, detail="Ukuran file melebihi 2 MB")

    # Amankan nama file: buang karakter path, sisakan nama dasar saja.
    safe_name = Path(file.filename).name
    destination = UPLOAD_DIR / safe_name
    destination.write_bytes(content)

    return {"filename": safe_name, "size": len(content)}

Jangan percaya content_type bulat-bulat. Nilai ini datang dari client dan bisa dipalsukan — file .exe bisa diklaim sebagai image/jpeg. Untuk validasi yang lebih serius, cek isi byte file (magic number) dengan library seperti python-magic. Untuk latihan atau tools internal, cek content_type biasanya sudah cukup.

Sajikan file yang sudah diupload

File yang tersimpan di folder uploads bisa langsung disajikan lewat StaticFiles. Mount folder tersebut ke sebuah path, dan file uploads/laporan.txt bisa diakses lewat /files/laporan.txt.

python
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

app = FastAPI()

app.mount("/files", StaticFiles(directory="uploads"), name="files")

Testing endpoint upload dengan pytest

TestClient dari Starlette punya cara ringkas untuk menyimulasikan upload lewat parameter files — kamu tinggal menyuplai tuple berisi nama file, isi bytes, dan content type. Kalau kamu baru mulai testing FastAPI, baca dulu panduan testing FastAPI dengan pytest.

python
from fastapi.testclient import TestClient

from main import app

client = TestClient(app)


def test_upload_berhasil():
    response = client.post(
        "/upload/",
        files={"file": ("laporan.txt", b"isi laporan", "text/plain")},
    )
    assert response.status_code == 200
    assert response.json()["filename"] == "laporan.txt"


def test_tolak_tipe_file_terlarang():
    response = client.post(
        "/upload/",
        files={"file": ("virus.exe", b"\x4d\x5a", "application/x-msdownload")},
    )
    assert response.status_code == 415

Jalankan pytest dari folder yang sama dengan main.py. Kalau kamu memisahkan kode ke modul berbeda, pastikan import main tetap menemukan aplikasinya — trik paling cepat adalah menaruh file test di root project dan menjalankan python -m pytest.

Berapa batas ukuran file yang wajar untuk diupload?

Tergantung jenis file. Untuk gambar profil atau dokumen, 2–5 MB biasanya cukup; untuk video atau dataset, kamu mungkin perlu puluhan MB. Kuncinya bukan sekadar angka, tapi memastikan server punya cukup disk dan proses upload tidak memblokir worker terlalu lama. Untuk file besar, pertimbangkan upload chunked atau langsung ke object storage seperti S3.

Bagaimana cara menyimpan file ke S3 atau object storage lain?

Alih-alih menulis ke disk lokal, kirim isi file ke bucket lewat SDK seperti boto3. UploadFile punya atribut file.file yang bisa dibaca per-chunk, jadi kamu bisa streaming langsung tanpa menyimpan sementara di server. Pola ini penting kalau aplikasi kamu dijalankan di beberapa instance sekaligus.

Kenapa upload saya gagal dengan pesan python-multipart tidak terinstall?

FastAPI tidak menarik python-multipart sebagai dependensi bawaan. Install dengan pip install python-multipart, lalu restart server. Pesan itu muncul justru karena FastAPI sudah menerima request multipart tapi tidak punya parser untuk membacanya.

Endpoint upload yang rapi tidak berhenti di sini. Langkah berikutnya yang paling terasa manfaatnya adalah membungkus endpoint dengan autentikasi supaya tidak sembarang orang bisa menimpa file, lalu memindahkan penyimpanan ke object storage kalau aplikasimu mulai dijalankan di banyak instance. Coba dulu tiga validasi di atas, tambahkan magic-number check, dan ukur berapa besar file terbesar yang endpoint kamu masih tangani dengan nyaman.