Skip to content

fix: Preserve empty strings in multipart/form-data requests (#4204)#4271

Merged
provinzkraut merged 9 commits intolitestar-org:mainfrom
doubledare704:fix/issue-4204-empty-strings-multipart
Sep 10, 2025
Merged

fix: Preserve empty strings in multipart/form-data requests (#4204)#4271
provinzkraut merged 9 commits intolitestar-org:mainfrom
doubledare704:fix/issue-4204-empty-strings-multipart

Conversation

@doubledare704
Copy link
Copy Markdown
Contributor

Description

  • Fix issue Bug: Empty strings in multipart/form-data requests are converted to None #4204 where empty strings in multipart forms were converted to None
  • Change boolean check to length check in _multipart.py line 114
  • Empty bytearrays now decode to empty strings instead of becoming None
  • Add comprehensive tests for empty string handling in multipart forms
  • Raw multipart content parsing works correctly
  • Existing functionality remains unaffected

Closes #4204

@doubledare704 doubledare704 requested review from a team as code owners August 24, 2025 07:17
@github-actions github-actions bot added area/kwargs area/multipart area/private-api This PR involves changes to the privatized API size: small pr/external Triage Required 🏥 This requires triage labels Aug 24, 2025
@doubledare704 doubledare704 changed the title Fix: Preserve empty strings in multipart/form-data requests (#4204) fix: Preserve empty strings in multipart/form-data requests (#4204) Aug 24, 2025
@doubledare704 doubledare704 force-pushed the fix/issue-4204-empty-strings-multipart branch from 4fd77f1 to 31286df Compare August 24, 2025 07:25
@codecov
Copy link
Copy Markdown

codecov bot commented Aug 24, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.25%. Comparing base (4c582da) to head (386cc5a).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4271      +/-   ##
==========================================
- Coverage   98.25%   98.25%   -0.01%     
==========================================
  Files         343      343              
  Lines       15824    15822       -2     
  Branches     1746     1745       -1     
==========================================
- Hits        15548    15546       -2     
  Misses        138      138              
  Partials      138      138              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@doubledare704 doubledare704 force-pushed the fix/issue-4204-empty-strings-multipart branch from 31286df to d380e39 Compare August 24, 2025 07:50
@doubledare704 doubledare704 marked this pull request as draft August 24, 2025 07:55
@doubledare704 doubledare704 force-pushed the fix/issue-4204-empty-strings-multipart branch 5 times, most recently from eb2e4ea to 5d3d9e3 Compare August 24, 2025 08:49
@doubledare704 doubledare704 marked this pull request as ready for review August 24, 2025 08:55
@doubledare704 doubledare704 marked this pull request as draft August 24, 2025 08:56
@doubledare704 doubledare704 force-pushed the fix/issue-4204-empty-strings-multipart branch from 5d3d9e3 to 41a11e9 Compare August 24, 2025 08:58
- Create a copy of form_values to handle type conversions
- Prevents type error when assigning None to dict[str, Any]
- Maintains all functionality while satisfying type checker
- All tests continue to pass

Fix: Preserve empty strings in multipart/form-data requests for GitHub issue litestar-org#4204

- Fix issue litestar-org#4204 where empty strings in multipart forms were converted to None
- Change boolean check to length check in _multipart.py line 114
- Empty bytearrays now decode to empty strings instead of becoming None
- Add comprehensive tests for empty string handling in multipart forms
- Ensure consistency between URL-encoded and multipart form behavior

The fix changes 'elif data:' to 'elif len(data) > 0:' and handles
the empty case by decoding the empty bytearray to an empty string.

Tests verify:
- Empty strings are preserved in multipart forms
- Consistency between URL-encoded and multipart behavior
- Raw multipart content parsing works correctly
- Existing functionality remains unaffected
@doubledare704 doubledare704 force-pushed the fix/issue-4204-empty-strings-multipart branch from 41a11e9 to 9c9c45a Compare August 24, 2025 09:02
@doubledare704 doubledare704 marked this pull request as ready for review August 24, 2025 09:08
Copy link
Copy Markdown
Member

@provinzkraut provinzkraut left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix is way to complex and imposes significant additional runtime overhead. Instead of trying to remove the unwanted Nones, the behaviour should be fixed during multipart parsing. Extracting the multipart data shouldn't include that much more special casing and trickery than doing the same for URL encoded form data, which already works as expected

@doubledare704 doubledare704 force-pushed the fix/issue-4204-empty-strings-multipart branch from 0261fcc to fbf61b8 Compare August 26, 2025 07:30
@github-actions
Copy link
Copy Markdown

Documentation preview will be available shortly at https://litestar-org.github.io/litestar-docs-preview/4271

Copy link
Copy Markdown
Member

@provinzkraut provinzkraut left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This already looks better. Still, a couple of suggestions for the tests

@doubledare704 doubledare704 force-pushed the fix/issue-4204-empty-strings-multipart branch from 9c44181 to cd30507 Compare September 5, 2025 06:44
@doubledare704 doubledare704 force-pushed the fix/issue-4204-empty-strings-multipart branch from 0fcd6e3 to 5be1952 Compare September 5, 2025 06:49
@provinzkraut provinzkraut enabled auto-merge (squash) September 10, 2025 17:31
@provinzkraut provinzkraut merged commit 7979d6d into litestar-org:main Sep 10, 2025
25 checks passed
@doubledare704 doubledare704 deleted the fix/issue-4204-empty-strings-multipart branch September 12, 2025 20:30
provinzkraut added a commit that referenced this pull request Sep 21, 2025
…4271)

---------

Co-authored-by: Janek Nouvertné <provinzkraut@posteo.de>
(cherry picked from commit 7979d6d)
@gsakkis
Copy link
Copy Markdown
Contributor

gsakkis commented Oct 7, 2025

After upgrading to 2.18 some of my tests broke due to this change. Ultimately it's an issue on my end but it took some time to figure it out, plus there are some difference between DTO (PydanticDTO specifically) and non-DTO (Pydantic BaseModel).

In short:

  • There is a MyForm model containing an UploadFile and a nullable field.
  • In some tests I was passing {field: None} as form data and apparently http clients (or at least httpx) converts None to empty string.
  • In 2.17 this works (i.e. the empty string is validated successfully as None) both when annotating the data as MyForm and as DTOData[MyForm].
  • In 2.18 it fails with
    • HTTP 400 (litestar.ValidationException) if annotated as MyForm
    • HTTP 500 (msgspec.ValidationError) if annotated as DTOData[MyForm] with dto=PydanticDTO[MyForm]
  • Unrelated to this PR but while playing around I discovered that (both in 2.17 and 2.18) the string "null" is surprisingly converted to None in the DTO case (but not in the plain BaseModel case). Not sure if this is a bug, a feature or neither.
    • Update: it's even stranger, apparently it's case insensitive - "NULL" and "Null" are also converted to None.

I can post a MCVE if interested.

@euri10
Copy link
Copy Markdown
Contributor

euri10 commented Oct 8, 2025

I can post a MCVE if interested.

i think you should

@gsakkis
Copy link
Copy Markdown
Contributor

gsakkis commented Oct 8, 2025

  • Server
from datetime import date
from typing import Annotated, Any

from litestar import Litestar, post
from litestar.datastructures import UploadFile
from litestar.dto import DTOData
from litestar.enums import RequestEncodingType
from litestar.params import Body
from litestar.plugins.pydantic import PydanticDTO
from pydantic import BaseModel


class MyForm(BaseModel, arbitrary_types_allowed=True):
    script: UploadFile
    updated_on: date | None = None


@post("/")
async def process(
    data: Annotated[MyForm, Body(media_type=RequestEncodingType.MULTI_PART)],
) -> dict[str, Any]:
    return {"filename": data.script.filename, "updated_on": data.updated_on}


@post("/dto", dto=PydanticDTO[MyForm])
async def process_dto(
    data: Annotated[DTOData[MyForm], Body(media_type=RequestEncodingType.MULTI_PART)],
) -> dict[str, Any]:
    builtins = data.as_builtins()
    return {
        "filename": builtins["script"].filename,
        "updated_on": builtins["updated_on"],
    }


app = Litestar(route_handlers=[process, process_dto])
  • Client
import httpx

with open("upload.py", "rb") as f:
    files = {"script": f}

    for data in {}, {"updated_on": None}, {"updated_on": "null"}:
        print(f"\n--- data={data} ---\n")
        resp = httpx.post("http://localhost:8000", files=files, data=data)
        print(resp.text)
        print("-" * 120)
        resp = httpx.post("http://localhost:8000/dto", files=files, data=data)
        print(resp.text)
  • Output with 2.17
--- data={} ---

INFO - 2025-10-08 10:12:00,484 - httpx - _client - HTTP Request: POST http://localhost:8000 "HTTP/1.1 201 Created"
{"filename":"upload.py","updated_on":null}
------------------------------------------------------------------------------------------------------------------------
INFO - 2025-10-08 10:12:00,489 - httpx - _client - HTTP Request: POST http://localhost:8000/dto "HTTP/1.1 201 Created"
{"filename":"upload.py","updated_on":null}

--- data={'updated_on': None} ---

INFO - 2025-10-08 10:12:00,495 - httpx - _client - HTTP Request: POST http://localhost:8000 "HTTP/1.1 201 Created"
{"filename":"upload.py","updated_on":null}
------------------------------------------------------------------------------------------------------------------------
INFO - 2025-10-08 10:12:00,500 - httpx - _client - HTTP Request: POST http://localhost:8000/dto "HTTP/1.1 201 Created"
{"filename":"upload.py","updated_on":null}

--- data={'updated_on': 'null'} ---

INFO - 2025-10-08 10:12:00,509 - httpx - _client - HTTP Request: POST http://localhost:8000 "HTTP/1.1 400 Bad Request"
{"status_code":400,"detail":"Validation failed for POST /","extra":[{"message":"Input should be a valid date or datetime, input is too short","key":"updated_on"}]}
------------------------------------------------------------------------------------------------------------------------
INFO - 2025-10-08 10:12:00,515 - httpx - _client - HTTP Request: POST http://localhost:8000/dto "HTTP/1.1 201 Created"
{"filename":"upload.py","updated_on":null}
  • Output with 2.18

--- data={} ---

INFO - 2025-10-08 10:11:23,666 - httpx - _client - HTTP Request: POST http://localhost:8000 "HTTP/1.1 201 Created"
{"filename":"upload.py","updated_on":null}
------------------------------------------------------------------------------------------------------------------------
INFO - 2025-10-08 10:11:23,674 - httpx - _client - HTTP Request: POST http://localhost:8000/dto "HTTP/1.1 201 Created"
{"filename":"upload.py","updated_on":null}

--- data={'updated_on': None} ---

INFO - 2025-10-08 10:11:23,681 - httpx - _client - HTTP Request: POST http://localhost:8000 "HTTP/1.1 400 Bad Request"
{"status_code":400,"detail":"Validation failed for POST /","extra":[{"message":"Input should be a valid date or datetime, input is too short","key":"updated_on"}]}
------------------------------------------------------------------------------------------------------------------------
INFO - 2025-10-08 10:11:23,688 - httpx - _client - HTTP Request: POST http://localhost:8000/dto "HTTP/1.1 500 Internal Server Error"
{"status_code":500,"detail":"Internal Server Error"}

--- data={'updated_on': 'null'} ---

INFO - 2025-10-08 10:11:23,694 - httpx - _client - HTTP Request: POST http://localhost:8000 "HTTP/1.1 400 Bad Request"
{"status_code":400,"detail":"Validation failed for POST /","extra":[{"message":"Input should be a valid date or datetime, input is too short","key":"updated_on"}]}
------------------------------------------------------------------------------------------------------------------------
INFO - 2025-10-08 10:11:23,699 - httpx - _client - HTTP Request: POST http://localhost:8000/dto "HTTP/1.1 201 Created"
{"filename":"upload.py","updated_on":null}

infraAnchor added a commit to infraAnchor/litestar that referenced this pull request Mar 31, 2026
…n no file submitted

When a browser submits a multipart form without selecting a file, the field
is sent as an empty string (""). Since 2.18.0 (PR litestar-org#4271 which fixed litestar-org#4204),
this empty string is no longer converted to None, causing validation to fail
for Optional[UploadFile] fields with a 400 error.

Fix: in _extract_multipart, before the type-hints loop, detect when a field
value is "" and the expected type is Optional[UploadFile] (or a subclass),
and replace the empty string with None before it reaches the conversion layer.

Fixes litestar-org#4647
provinzkraut pushed a commit that referenced this pull request Apr 2, 2026
…ltipart form (#4659)

* fix: Optional[UploadFile] with multipart/form-data should be None when no file submitted

When a browser submits a multipart form without selecting a file, the field
is sent as an empty string (""). Since 2.18.0 (PR #4271 which fixed #4204),
this empty string is no longer converted to None, causing validation to fail
for Optional[UploadFile] fields with a 400 error.

Fix: in _extract_multipart, before the type-hints loop, detect when a field
value is "" and the expected type is Optional[UploadFile] (or a subclass),
and replace the empty string with None before it reaches the conversion layer.

Fixes #4647

* fix: add type annotation for inner to satisfy mypy

* refactor: simplify Optional[UploadFile] check using isinstance guard
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Empty strings in multipart/form-data requests are converted to None

4 participants