W FastAPI plik odbierasz parametrem typu `UploadFile`, co wymaga zainstalowania pakietu `python-multipart`. Typ i rozmiar sprawdzasz sam w kodzie (rozszerzenie, pierwsze bajty pliku, limit czytany porcjami), a plik zapisujesz pod własną nazwą, nigdy pod tą od klienta. Dane tekstowe wysyłane razem z plikiem odbierasz przez `Form`, bo plik i ciało JSON nie mogą być w jednym żądaniu.
Wgranie pliku to pierwszy moment, w którym Twoje API przyjmuje od obcej osoby coś więcej niż kilka liczb i napisów. Poniżej przechodzisz od najprostszego endpointu do wersji, którą można wystawić użytkownikom. Jeśli nie znasz jeszcze podstaw, zacznij od pierwszego API w FastAPI. Upload widziany z przeglądarki (wybór pliku, FileReader, wstępna walidacja) opisuje osobny tekst o uploadzie plików po stronie frontendu. Tu skupiamy się na serwerze.
Jak odebrać plik w FastAPI?
Zainstaluj FastAPI razem z python-multipart. Ten pakiet czyta żądania typu multipart/form-data, czyli format, którym przeglądarka i klienci HTTP wysyłają pliki:
pip install fastapi uvicorn python-multipart
Najprostszy endpoint ma jeden parametr z typem UploadFile:
from fastapi import FastAPI, UploadFile
app = FastAPI()
@app.post("/pliki")
async def wgraj(plik: UploadFile):
return {"nazwa": plik.filename, "typ": plik.content_type, "rozmiar": plik.size}
Uruchom aplikację przez uvicorn main:app --reload i otwórz /docs. FastAPI sam pokaże przy tym endpoincie przycisk wyboru pliku, więc możesz go przetestować bez własnego klienta. Po wgraniu zdjęcia dostaniesz na przykład {"nazwa": "zdjecie.png", "typ": "image/png", "rozmiar": 108}.
Nazwa parametru (plik) jest jednocześnie nazwą pola w formularzu. Klient musi wysłać plik pod tą samą nazwą. Jeśli pominie pole, dostanie błąd 422 z komunikatem Field required i lokalizacją ["body", "plik"]. Jak czytać takie błędy, opisuje artykuł o walidacji danych w FastAPI i Pydantic.
Co oznacza błąd „Form data requires python-multipart”?
Jeśli zadeklarujesz UploadFile albo Form, a pakietu python-multipart nie ma w środowisku, aplikacja nie wystartuje i zobaczysz komunikat:
Form data requires "python-multipart" to be installed.
You can install "python-multipart" with:
pip install python-multipart
Rozwiązanie to pip install python-multipart w tym samym środowisku wirtualnym, z którego uruchamiasz uvicorn. Najczęstsza przyczyna, gdy pakiet „jest zainstalowany, a błąd dalej się pojawia”, to dwa różne interpretery: pakiet trafił do jednego środowiska, a serwer startuje z drugiego. Sprawdź pip show python-multipart tym samym poleceniem python -m pip, którym uruchamiasz aplikację. Pamiętaj też o dopisaniu pakietu do requirements.txt, bo na serwerze produkcyjnym ten sam błąd pojawi się ponownie.
UploadFile czy bytes: czym się różnią?
Plik można też odebrać jako bytes (plik: Annotated[bytes, File()]). To działa, ale cały plik ląduje wtedy w pamięci. UploadFile jest ostrożniejszy:
bytes | UploadFile | |
|---|---|---|
| Gdzie jest plik | w całości w pamięci | w pamięci do 1 MB, powyżej na dysku (plik tymczasowy) |
| Nazwa i typ od klienta | brak | filename, content_type |
| Odczyt | od razu cała zawartość | porcjami: await plik.read(rozmiar) |
| Kiedy używać | bardzo małe pliki, tylko w testach | prawie zawsze |
Pod spodem UploadFile.file to SpooledTemporaryFile. Sprawdziłem to na własnym kodzie: plik 0,5 MB zostaje w pamięci, a plik 5 MB jest już zapisany w pliku tymczasowym na dysku. Dzięki temu kilka równoczesnych uploadów dużych plików nie zapełni pamięci serwera.
Jak wysłać plik razem z polami formularza?
To najczęstsze pytanie początkujących: chcesz wysłać plik i obiekt JSON (na przykład tytuł dokumentu) w jednym żądaniu. Nie da się. Żądanie multipart/form-data nie może mieć jednocześnie ciała application/json. Zamiast JSON-a używasz pól formularza, a w FastAPI odbierasz je przez Form:
from typing import Annotated
from fastapi import Form, UploadFile
@app.post("/dokumenty", status_code=201)
async def wgraj_dokument(
plik: UploadFile,
tytul: Annotated[str, Form(min_length=3)],
):
...
Pola Form walidujesz tak samo jak zwykłe parametry. Tytuł „T” zwróci błąd 422 z typem string_too_short. Jeśli potrzebujesz bardziej złożonych danych, możesz wysłać je jako tekst JSON w jednym polu formularza i sparsować w endpoincie, ale zwykle prostsze jest kilka osobnych pól.
Jak zwalidować typ i rozmiar pliku?
Samo odebranie pliku nie wystarczy. Pełna wersja endpointu sprawdza rozszerzenie, początek pliku i rozmiar:
import uuid
from pathlib import Path
from fastapi import HTTPException
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
MAX_SIZE = 2 * 1024 * 1024 # 2 MB
CHUNK = 64 * 1024
# rozszerzenie -> początek pliku (magic bytes)
ALLOWED = {
".png": b"\x89PNG\r\n\x1a\n",
".jpg": b"\xff\xd8\xff",
".pdf": b"%PDF-",
}
@app.post("/dokumenty", status_code=201)
async def wgraj_dokument(
plik: UploadFile,
tytul: Annotated[str, Form(min_length=3)],
):
extension = Path(plik.filename or "").suffix.lower()
signature = ALLOWED.get(extension)
if signature is None:
raise HTTPException(415, f"Niedozwolone rozszerzenie: {extension or 'brak'}")
first = await plik.read(len(signature))
if first != signature:
raise HTTPException(415, "Zawartość pliku nie pasuje do rozszerzenia")
target = UPLOAD_DIR / f"{uuid.uuid4().hex}{extension}"
size = len(first)
with target.open("wb") as out:
out.write(first)
while chunk := await plik.read(CHUNK):
size += len(chunk)
if size > MAX_SIZE:
out.close()
target.unlink()
raise HTTPException(413, "Plik jest za duży (limit 2 MB)")
out.write(chunk)
return {"tytul": tytul, "zapisano_jako": target.name, "rozmiar": size}
Przetestowałem ten kod na czterech przypadkach i każdy kończy się innym statusem:
| Przypadek | Status | Dlaczego |
|---|---|---|
| Poprawny PNG z tytułem | 201 | plik zapisany pod nową nazwą |
Plik a.exe | 415 | rozszerzenie spoza listy |
Plik a.png z treścią „to nie jest png” | 415 | pierwsze bajty nie pasują do PNG |
| PNG większy niż 2 MB | 413 | limit przekroczony, częściowy plik usunięty |
Kody mają znaczenie: 415 oznacza zły typ zawartości, 413 za duże ciało żądania, a 422 błąd walidacji pól. Dzięki nim klient (na przykład frontend) wie, co pokazać użytkownikowi.
Dlaczego nie można ufać content_type i nazwie pliku?
Oba pola ustawia klient. Przeglądarka robi to uczciwie, ale każdy, kto wyśle żądanie ręcznie (curl, httpx, skrypt), wpisze dowolny content_type i dowolną nazwę. Plik wykonywalny przemianowany na zdjecie.png z nagłówkiem image/png przejdzie więc sprawdzenie, które patrzy tylko na te dwa pola. Dlatego w kodzie wyżej rozszerzenie z listy dozwolonych plus pierwsze bajty pliku (tzw. magic bytes, czyli sygnatura formatu). To nadal nie jest pełna weryfikacja (poprawny początek nie gwarantuje, że reszta pliku jest w porządku). Dla obrazków dobrym uzupełnieniem jest próba ich otwarcia biblioteką graficzną, na przykład Pillow.
Gdzie naprawdę ograniczyć rozmiar pliku?
Pętla z MAX_SIZE przerywa zapis i usuwa plik, ale zanim endpoint się uruchomi, FastAPI musi odczytać całe żądanie. W moim teście handler widział już kompletny plik (plik.size równe rozmiarowi całości). Limit w kodzie chroni więc dysk, a nie łącze i czas serwera. Pierwszą linią obrony powinien być serwer przed aplikacją: w nginx dyrektywa client_max_body_size, a na platformach hostingowych odpowiednie ustawienie limitu żądania. Limit w FastAPI traktuj jako drugą, ostatnią zaporę.
Jak bezpiecznie zapisać plik na dysku?
Trzy zasady, które widać w kodzie powyżej:
- Własna nazwa pliku. Nazwę generujesz sam (
uuid.uuid4().hexplus rozszerzenie z listy). Oryginalną nazwę zplik.filenamemożesz zapisać w bazie jako informację dla użytkownika, ale nie używaj jej jako ścieżki. Nazwa taka jak../../etc/x.pngzapisana wprost mogłaby wyjść poza kataloguploads. W moim teście przy takiej nazwie plik trafił douploadspod nazwą wygenerowaną przezuuid. Jeśli nazwa pliku trafia później do polecenia systemowego, zobacz command injection. - Osobny katalog poza kodem aplikacji. Wgrane pliki nie powinny leżeć obok plików, które serwer wykonuje lub serwuje jako statyczne bez kontroli.
- Zapis porcjami.
await plik.read(CHUNK)w pętli czyta po 64 KB, więc pamięć nie rośnie wraz z rozmiarem pliku.
Uwaga o wydajności: out.write(chunk) to zwykły, blokujący zapis wewnątrz async def. Przy małych porcjach i szybkim dysku nie widać problemu, ale to ta sama klasa błędu, którą opisuje artykuł o blokowaniu pętli w FastAPI. Przy dużym ruchu użyj biblioteki aiofiles albo przenieś zapis do zwykłej funkcji def, którą FastAPI uruchamia w osobnym wątku.
Jak wysłać kilka plików naraz?
Zadeklaruj listę UploadFile. Przy liście trzeba dodać File(), żeby FastAPI wiedział, że pole pochodzi z formularza:
from fastapi import File
@app.post("/galeria")
async def wgraj_wiele(pliki: Annotated[list[UploadFile], File()]):
return [{"nazwa": p.filename, "rozmiar": p.size} for p in pliki]
Klient wysyła wtedy kilka części o tej samej nazwie pola (pliki). Walidację z poprzedniej sekcji najlepiej wydzielić do osobnej funkcji i wywołać ją dla każdego pliku w pętli, zamiast kopiować kod.
Jak wysłać plik z przeglądarki i z Pythona?
Z przeglądarki użyj FormData i fetch. Nie ustawiaj ręcznie nagłówka Content-Type, bo przeglądarka sama doda go razem z granicą (boundary), bez której serwer nie rozpozna części:
const input = document.querySelector('input[type="file"]');
const data = new FormData();
data.append('plik', input.files[0]);
data.append('tytul', 'Umowa');
const response = await fetch('http://localhost:8000/dokumenty', {
method: 'POST',
body: data,
});
console.log(response.status, await response.json());
Jeśli front działa na innym adresie niż API, przeglądarka zablokuje takie żądanie bez poprawnej konfiguracji nagłówków. Zobacz artykuł o CORS i błędach z nim związanych.
Z Pythona (skrypt, inna usługa, test ręczny) użyj httpx:
import httpx
with open("umowa.pdf", "rb") as f:
response = httpx.post(
"http://localhost:8000/dokumenty",
files={"plik": ("umowa.pdf", f, "application/pdf")},
data={"tytul": "Umowa"},
)
print(response.status_code, response.json())
Przy poprawnym pliku PDF serwer odpowie statusem 201 i nazwą zapisanego pliku. Krotka w files ma postać (nazwa, zawartość, typ), a zwykłe pola formularza idą osobno w data.
Jakie błędy mentor poprawia najczęściej?
Uwaga mentora: najczęstszy błąd to zaufanie do content_type i nazwy pliku od klienta. Oba pola wysyła przeglądarka (albo ktoś, kto przeglądarkę udaje), więc przy shell.exe przemianowanym na zdjecie.png serwer widzi „obrazek”. Sprawdź pierwsze bajty pliku i zapisuj pod własną nazwą.
Poniżej cała lista pułapek w jednym miejscu:
| Pułapka | Skutek | Jak uniknąć |
|---|---|---|
Zaufanie do content_type i filename | do aplikacji trafia dowolny plik pod postacią obrazka | rozszerzenie z listy dozwolonych i sprawdzenie pierwszych bajtów |
| Zapis pod nazwą od klienta | nadpisanie cudzego pliku lub wyjście poza katalog | własna nazwa (uuid), oryginał tylko w bazie |
await plik.read() bez argumentu na dużym pliku | cały plik w pamięci | czytanie porcjami |
| Limit tylko w kodzie | plik i tak zdążył dojść do aplikacji | limit na serwerze brzegowym plus drugi w kodzie |
Ręcznie ustawiony Content-Type w fetch | serwer odpowiada 422, bo brak boundary | zostaw nagłówek przeglądarce |
Brak python-multipart w requirements.txt | aplikacja działa lokalnie, a na serwerze nie startuje | dopisz pakiet do zależności |
Co dalej?
Masz endpoint, który przyjmuje plik, sprawdza go i zapisuje. Naturalne kolejne kroki to przechowywanie plików poza dyskiem serwera (usługa typu S3), przetwarzanie pliku w tle po wgraniu oraz przekazanie jego treści do modelu językowego, na przykład do streszczenia. Jak odsyłać odpowiedź modelu na bieżąco, pokazuje artykuł o streamingu odpowiedzi w FastAPI. Jeśli chcesz uczyć się backendu w Pythonie z mentorem, który ocenia Twój kod, sprawdź kurs Python z mentorem.


