Upload plików w FastAPI: UploadFile, walidacja i bezpieczny zapis

Piotr Dul

21-10-2026

(aktualizacja: 21-10-2026)

Jak odebrać plik w FastAPI: UploadFile, python-multipart, plik razem z polami formularza, kontrola typu i rozmiaru oraz bezpieczny zapis na dysku. Z kodem po stronie serwera, httpx i fetch.

Upload plików w FastAPI: UploadFile, walidacja i bezpieczny zapis
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:

bytesUploadFile
Gdzie jest plikw całości w pamięciw pamięci do 1 MB, powyżej na dysku (plik tymczasowy)
Nazwa i typ od klientabrakfilename, content_type
Odczytod razu cała zawartośćporcjami: await plik.read(rozmiar)
Kiedy używaćbardzo małe pliki, tylko w testachprawie 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:

PrzypadekStatusDlaczego
Poprawny PNG z tytułem201plik zapisany pod nową nazwą
Plik a.exe415rozszerzenie spoza listy
Plik a.png z treścią „to nie jest png”415pierwsze bajty nie pasują do PNG
PNG większy niż 2 MB413limit 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:

  1. Własna nazwa pliku. Nazwę generujesz sam (uuid.uuid4().hex plus rozszerzenie z listy). Oryginalną nazwę z plik.filename możesz zapisać w bazie jako informację dla użytkownika, ale nie używaj jej jako ścieżki. Nazwa taka jak ../../etc/x.png zapisana wprost mogłaby wyjść poza katalog uploads. W moim teście przy takiej nazwie plik trafił do uploads pod nazwą wygenerowaną przez uuid. Jeśli nazwa pliku trafia później do polecenia systemowego, zobacz command injection.
  2. Osobny katalog poza kodem aplikacji. Wgrane pliki nie powinny leżeć obok plików, które serwer wykonuje lub serwuje jako statyczne bez kontroli.
  3. 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łapkaSkutekJak uniknąć
Zaufanie do content_type i filenamedo aplikacji trafia dowolny plik pod postacią obrazkarozszerzenie z listy dozwolonych i sprawdzenie pierwszych bajtów
Zapis pod nazwą od klientanadpisanie cudzego pliku lub wyjście poza katalogwłasna nazwa (uuid), oryginał tylko w bazie
await plik.read() bez argumentu na dużym plikucały plik w pamięciczytanie porcjami
Limit tylko w kodzieplik i tak zdążył dojść do aplikacjilimit na serwerze brzegowym plus drugi w kodzie
Ręcznie ustawiony Content-Type w fetchserwer odpowiada 422, bo brak boundaryzostaw nagłówek przeglądarce
Brak python-multipart w requirements.txtaplikacja działa lokalnie, a na serwerze nie startujedopisz 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.

Konsultacja i warsztaty devmentor.pl

Masz pytania?