FastAPI

FastAPI File Upload

Thirdy Gayares
12 min read

Real-Life Example: Expense Receipt Upload API

Instead of a toy uploader, this tutorial builds a backend flow teams actually use: employees upload receipts, finance validates them, and status moves from pending_review to approved or rejected.

  • Uploads via multipart/form-data
  • Validation by content type, extension, and max size
  • Chunked file write + SHA-256 checksum
  • Metadata stored in SQLite for finance reporting
  • Simple auth with X-API-Key for internal APIs

Business Scenario and Requirements

The finance team needs one API for receipt intake. Every upload needs a file, amount, merchant, employee id, expense date, and category so reviewers can audit and approve quickly.

RequirementWhy it mattersImplementation
Accept JPG/PNG/PDFCommon receipt formatsValidate content type + extension
Block oversized filesAvoid storage abuseReject over 5MB (HTTP 413)
Keep file hashAudit and duplicate checksSHA-256 during streaming write
Track review stateFinance workflowpending_review / approved / rejected
Query by employeeMonthly reimbursementsGET /receipts?employee_id=...

Project Location in This Repo

Runnable sample created at examples/fastapi-file-upload. This is the same implementation described in this article.

examples/fastapi-file-upload/
├── .env.example
├── README.md
├── requirements.txt
├── app/
│   ├── __init__.py
│   ├── database.py
│   ├── main.py
│   └── settings.py
└── storage/
    └── receipts/

Run Locally

terminal
cd examples/fastapi-file-upload
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --reload
INFO:     Uvicorn running on http://127.0.0.1:8000
INFO:     Application startup complete.
Open docs: http://127.0.0.1:8000/docs

Configuration (settings.py)

Keep limits and auth in environment variables so the same code runs on local, staging, and prod with different values.

app/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    app_name: str = "Expense Receipt Upload API"
    api_key: str = "local-dev-key"
    max_upload_bytes: int = 5 * 1024 * 1024
    upload_dir: str = "storage/receipts"
    database_url: str = "sqlite:///./receipts.db"
    allowed_content_types: str = "image/jpeg,image/png,application/pdf"

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

    @property
    def allowed_content_type_set(self) -> set[str]:
        return {item.strip() for item in self.allowed_content_types.split(",") if item.strip()}


settings = Settings()
💡
Why this matters: product teams often tune upload limits per environment. Using env vars avoids hardcoded limits.

Upload Endpoint with Streaming + Validation

This endpoint accepts file + form fields in one request, writes the file in chunks, enforces max size, and stores hash and metadata.

app/main.py (excerpt)
CHUNK_SIZE = 1024 * 1024
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".pdf"}

@app.post("/receipts", response_model=ReceiptResponse, status_code=201, dependencies=[Depends(require_api_key)])
async def create_receipt(
    file: UploadFile = File(...),
    employee_id: str = Form(...),
    merchant: str = Form(...),
    amount: Decimal = Form(...),
    expense_date: date = Form(...),
    category: str = Form(default="other"),
    currency: str = Form(default="USD"),
):
    if file.content_type not in settings.allowed_content_type_set:
        raise HTTPException(status_code=400, detail="Unsupported content type")

    extension = Path(file.filename or "").suffix.lower()
    if extension not in ALLOWED_EXTENSIONS:
        raise HTTPException(status_code=400, detail="Unsupported file extension")

    receipt_id = uuid4().hex
    year_month = datetime.now(UTC).strftime("%Y/%m")
    target_dir = upload_root / year_month
    target_dir.mkdir(parents=True, exist_ok=True)

    target_path = target_dir / f"{receipt_id}{extension}"

    size = 0
    digest = hashlib.sha256()

    with target_path.open("wb") as out:
        while True:
            chunk = await file.read(CHUNK_SIZE)
            if not chunk:
                break
            size += len(chunk)
            if size > settings.max_upload_bytes:
                raise HTTPException(status_code=413, detail="File too large")
            digest.update(chunk)
            out.write(chunk)
⚠️
Important: streaming avoids loading full files into memory. This is safer for real traffic and large uploads.

Persistence Layer (SQLite)

Finance needs searchable records. We store receipt metadata in a table, while actual files stay on disk understorage/receipts/YYYY/MM.

app/database.py (excerpt)
def init_db() -> None:
    with sqlite3.connect(DB_PATH) as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS receipts (
                id TEXT PRIMARY KEY,
                employee_id TEXT NOT NULL,
                merchant TEXT NOT NULL,
                amount_cents INTEGER NOT NULL,
                currency TEXT NOT NULL,
                expense_date TEXT NOT NULL,
                category TEXT NOT NULL,
                original_filename TEXT NOT NULL,
                stored_path TEXT NOT NULL,
                content_type TEXT NOT NULL,
                file_size INTEGER NOT NULL,
                checksum_sha256 TEXT NOT NULL,
                status TEXT NOT NULL,
                notes TEXT,
                created_at TEXT NOT NULL
            )
            """
        )
        conn.commit()

Review Workflow Endpoints

The API supports both retrieval and review actions:

EndpointPurposeExample
POST /receiptsCreate expense receipt uploadUsed by employee submit form
GET /receiptsList receipts with filters?employee_id=emp-104
GET /receipts/{id}Fetch single receiptOpen one receipt detail
PATCH /receipts/{id}/statusFinance updates statusapproved / rejected
status update request
curl -X PATCH "http://127.0.0.1:8000/receipts/<receipt_id>/status"   -H "Content-Type: application/json"   -H "X-API-Key: local-dev-key"   -d '{"status":"approved"}'

End-to-End cURL Flow

upload request
curl -X POST "http://127.0.0.1:8000/receipts"   -H "X-API-Key: local-dev-key"   -F "file=@/absolute/path/to/receipt.jpg"   -F "employee_id=emp-104"   -F "merchant=Grab Transport"   -F "amount=18.45"   -F "expense_date=2026-02-27"   -F "category=travel"   -F "currency=PHP"
{
  "id": "8fbe524f8c7a4dbcbf0fd0cbf7d68e2a",
  "employee_id": "emp-104",
  "merchant": "Grab Transport",
  "amount_cents": 1845,
  "currency": "PHP",
  "expense_date": "2026-02-27",
  "category": "travel",
  "status": "pending_review",
  "stored_path": "2026/02/8fbe524f8c7a4dbcbf0fd0cbf7d68e2a.jpg"
}

Production Notes Before Shipping

AreaDemo approachProduction upgrade
StorageLocal diskS3/GCS + signed URLs
AuthStatic API keyJWT/session with user identity
SecurityType + size validationMalware scanning and MIME sniffing
DatabaseSQLitePostgreSQL + indexes + audit fields
ObservabilityApp logsStructured logs + metrics + alerts
This is now a practical baseline: teams can submit receipts, finance can review them, and every file has traceable metadata.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.