Post

Custom Response

Let's return Custom Response

Custom Response

FastAPI uses JSONResponse by default to automatically convert responses into JSON. Therefore, you don’t need to explicitly use JSONResponse for types like dict, Pydantic models, list/tuple/set, datetime, or UUID etc.

However, types such as HTML content, file handles, database sessions, or custom classes are not automatically serialized, so you need to use HTMLResponse, StreamingResponse, FileResponse, or handle the conversion manually.

Understanding media_type

Before learning about Custom Response, let’s first understand media_type.

In FastAPI, the media_type parameter (also known as the MIME type or Content-Type) is used to specify the format of the response that your API endpoint will return. It tells the client how to interpret the data sent from the server.

media_type is a string that indicates the format of the response body. Common examples include:

Media TypeDescription
application/jsonJSON-encoded data (default in FastAPI)
text/plainPlain text response
text/htmlHTML content
application/xmlXML-encoded data
application/x-ndjsonNewline-delimited JSON, used for streaming JSON lines
text/event-streamServer-Sent Events (SSE) format for real-time updates
video/mp4MP4 video content

NDJSON stands for Newline Delimited JSON.
It is a streaming-friendly JSON format where each line is a valid JSON object, separated by newline characters (\n). NDJSON is typically used with the media type application/x-ndjson.

1
2
3
{"id": 1, "message": "hello"}
{"id": 2, "message": "world"}
{"id": 3, "message": "!"}

“The comparison between NDJSON and regular JSON is shown below:

CategoryNDJSONRegular JSON
FormatOne JSON object per lineA single JSON array containing objects
Streaming friendlyYes (line-by-line processing)No (requires full payload)
Use casesLogs, chat streams, real-time APIsStatic or batch responses

Automatic JSON Conversion

I wrote the following three sample codes. They all produce the same result depending on the type used. In fact, since FastAPI automatically converts the response to JSON, you don’t need to explicitly use JSONResponse(The default media_type of JSONResponse is application/json.).

The code below is used to print information about the response.

  • async def log_response_info(response: aiohttp.ClientResponse)
  • async def read_response(response: aiohttp.ClientResponse)
  • async def read_streaming_response(response: aiohttp.ClientResponse)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import aiohttp
from aiohttp import ClientResponseError 
from pprint import pprint

async def log_response_info(response: aiohttp.ClientResponse):
    print("\n[Response Info]")
    print(f"Status       : {response.status} {response.reason}")
    print(f"URL          : {response.url}")
    print(f"Method       : {response.method}")
    print(f"Content-Type : {response.content_type}")
    print(f"Charset      : {response.charset}")
    print(f"Cookies      : {dict(response.cookies)}")
    print(f"Headers:")
    pprint(dict(response.headers))
    print(f"OK?          : {response.ok}")

async def read_response(response: aiohttp.ClientResponse):
    try:
        data = await response.json()
        print(f"\nJSON Body:")
        print(f"type(data): {type(data)}")
        pprint(data)
    except aiohttp.ContentTypeError:
        text = await response.text()
        print(f"\nText Body:\n{text}")

async def read_streaming_response(response: aiohttp.ClientResponse):
    print("\n[Streaming Body]")
    async for chunk in response.content:
        if chunk:
            print(chunk.decode().strip(), flush=True)

try:
    url = "http://127.0.0.1:7249/ds2man/basic/response/case1"
    async with aiohttp.ClientSession(
        trust_env=True, timeout=aiohttp.ClientTimeout(total=120)
    ) as session:
        async with session.get(url) as response:
            response.raise_for_status()
            await log_response_info(response)
            await read_response(response)
            await read_streaming_response(response) # For StreamingResponse
except ClientResponseError as e:
        print(f"[ClientResponseError] {e.status}: {e.message}")
except Exception as e:
    print(f"[Unhandled Exception] {e}")

Type like dict

1
2
3
4
5
6
7
8
9
@router.get("/response/case1")
def response_case1():
    result = {
        "user_name": "DS2Man",
        "message": "Hello, World!"
    }

    # return JSONResponse(content=result) # same result
    return result    
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Response Info]
Status       : 200 OK
URL          : http://127.0.0.1:7249/ds2man/basic/response/case1
Method       : GET
Content-Type : application/json
Charset      : None
Cookies      : {}
Headers:
{'Content-Length': '48',
 'Content-Type': 'application/json',
 'Date': 'Fri, 02 May 2025 20:28:14 GMT',
 'Server': 'uvicorn'}
OK?          : True

JSON Body:
type(data): <class 'dict'>
{'message': 'Hello, World!', 'user_name': 'DS2Man'}

Pydantic models

1
2
3
4
5
6
7
8
9
10
class User(BaseModel):
    id : int
    name : str
    
@router.get("/response/case2")
def response_case2():
    result = User(id=1, name="DS2Man")

    # return JSONResponse(content=result.model_dump()) # same result
    return result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Response Info]
Status       : 200 OK
URL          : http://127.0.0.1:7249/ds2man/basic/response/case2
Method       : GET
Content-Type : application/json
Charset      : None
Cookies      : {}
Headers:
{'Content-Length': '24',
 'Content-Type': 'application/json',
 'Date': 'Fri, 02 May 2025 20:28:41 GMT',
 'Server': 'uvicorn'}
OK?          : True

JSON Body:
type(data): <class 'dict'>
{'id': 1, 'name': 'DS2Man'}

list/tuple/set

1
2
3
4
5
6
@router.get("/response/case3")
def response_case2():
    result = [User(id=1, name="DS2Man"), User(id=2, name="YongCheol")]

    # return JSONResponse(content=[user.model_dump() for user in result]) # same result
    return result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Response Info]
Status       : 200 OK
URL          : http://127.0.0.1:7249/ds2man/basic/response/case3
Method       : GET
Content-Type : application/json
Charset      : None
Cookies      : {}
Headers:
{'Content-Length': '54',
 'Content-Type': 'application/json',
 'Date': 'Fri, 02 May 2025 20:29:03 GMT',
 'Server': 'uvicorn'}
OK?          : True

JSON Body:
type(data): <class 'list'>
[{'id': 1, 'name': 'DS2Man'}, {'id': 2, 'name': 'YongCheol'}]

Beyond JSON: Serving HTML, Files, and Custom Types

FastAPI is known for its powerful automatic JSON conversion, but there are cases where you may want to return raw HTML content, perform redirections, or stream data.

Here, I’ll briefly go over a few simple examples. As the project progresses, I’ll continue to add important notes you should be aware of. For more details or if you have questions, please refer to the official FastAPI documentation.

HTMLResponse

The default media_type of HTMLResponse is text/html.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def generate_html_response():
    html_content = """
    <html>
        <head>
            <title>Some HTML in here</title>
        </head>
        <body>
            <h1>Look! HTML!</h1>
        </body>
    </html>
    """
    return HTMLResponse(content=html_content, status_code=200)

@router.get("/response/case4")
async def response_case4():
    return generate_html_response()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[Response Info]
Status       : 200 OK
URL          : http://127.0.0.1:7249/ds2man/basic/response/case4
Method       : GET
Content-Type : text/html
Charset      : utf-8
Cookies      : {}
Headers:
{'Content-Length': '171',
 'Content-Type': 'text/html; charset=utf-8',
 'Date': 'Fri, 02 May 2025 20:31:16 GMT',
 'Server': 'uvicorn'}
OK?          : True

Text Body:

    <html>
        <head>
            <title>Some HTML in here</title>
        </head>
        <body>
            <h1>Look! HTML!</h1>
        </body>
    </html>

RedirectResponse

1
2
3
4
@router.get("/response/case5")
async def response_case5():
    redirect_url = "/ds2man/basic/response/case4"
    return RedirectResponse(redirect_url)

As shown in the logs below, FastAPI returns a 307 Temporary Redirect status code, and then automatically follows the redirect to complete the request with a 200 OK.

1
2
INFO:     127.0.0.1:7711 - "GET /ds2man/basic/response/case5 HTTP/1.1" 307 Temporary Redirect
INFO:     127.0.0.1:7711 - "GET /ds2man/basic/response/case4 HTTP/1.1" 200 OK
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[Response Info]
Status       : 200 OK
URL          : http://127.0.0.1:7249/ds2man/basic/response/case4
Method       : GET
Content-Type : text/html
Charset      : utf-8
Cookies      : {}
Headers:
{'Content-Length': '171',
 'Content-Type': 'text/html; charset=utf-8',
 'Date': 'Fri, 02 May 2025 20:31:48 GMT',
 'Server': 'uvicorn'}
OK?          : True

Text Body:

    <html>
        <head>
            <title>Some HTML in here</title>
        </head>
        <body>
            <h1>Look! HTML!</h1>
        </body>
    </html>

StreamingResponse

StreamingResponse is a response class provided by FastAPI (and Starlette) that allows you to send data to the client in chunks as it becomes available — rather than waiting for the entire response to be ready.

This is especially useful for large files, real-time data, or long-running processes, where you want to progressively stream parts of the response.

Because of its usefulness, it’s important to choose the appropriate media_type depending on the context. The media_type serves as a hint to the client on how to parse the response, so specifying the correct Content-Type is even more critical in StreamingResponse than in other types of custom responses.

That said, during testing, you might observe that “the output appears the same regardless of media_type.” However, even if the behavior looks identical, using an incorrect or mismatched format may cause parsing failures or functional errors on the client side. So please be mindful when using it.

Since each chunk is plain text separated by \n without any event format or JSON structure, "text/plain" is appropriate.

1
2
3
4
5
6
7
8
9
async def fake_stream_generator():
    for chunk in ["DS", "2", "Man"]:
        await sleep(0.5)
        yield chunk + "\n"

@router.get("/case1")
async def stream_case1():
    # Since each chunk is plain text separated by `\n` without any event format or JSON structure, `"text/plain"` is appropriate.
    return StreamingResponse(fake_stream_generator(), media_type="text/plain")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
--------------
[Response Info]
Status       : 200 OK
URL          : http://127.0.0.1:7249/ds2man/basic-streaming/case1?key1=value1&key2=value2
Method       : GET
Content-Type : text/plain
Charset      : utf-8
Cookies      : {}
Headers:
{'Content-Type': 'text/plain; charset=utf-8',
 'Date': 'Sun, 11 May 2025 15:14:42 GMT',
 'Server': 'uvicorn',
 'Transfer-Encoding': 'chunked',
 'myprocess-time': '0.00099945068359375',
 'request-url': '/ds2man/basic-streaming/case1'}
OK?          : True
--------------
--------------
[Streaming Body]
DS
2
Man

text/event-stream is a format used for Server-Sent Events (SSE).
To use it properly, you must include a blank line (\n\n) to indicate the end of a message block.
On the client side, messages are received using the EventSource API.

1
2
3
4
5
6
7
8
9
10
11
12
async def fake_sse_stream_generator():
    for chunk in ["DS", "2", "Man"]:
        await sleep(0.5)
        yield chunk + "\n\n"

@router.get("/case2")
async def stream_case2():
    # `text/event-stream` is a format used for Server-Sent Events (SSE).  
    #To use it properly, you must include a blank line (`\n\n`) to indicate the end of a message block.  
    # On the client side, messages are received using the `EventSource` API.
    return StreamingResponse(fake_sse_stream_generator(), media_type="text/event-stream")

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
--------------
[Response Info]
Status       : 200 OK
URL          : http://127.0.0.1:7249/ds2man/basic-streaming/case2?key1=value1&key2=value2
Method       : GET
Content-Type : text/event-stream
Charset      : utf-8
Cookies      : {}
Headers:
{'Content-Type': 'text/event-stream; charset=utf-8',
 'Date': 'Sun, 11 May 2025 15:15:45 GMT',
 'Server': 'uvicorn',
 'Transfer-Encoding': 'chunked',
 'myprocess-time': '0.0010013580322265625',
 'request-url': '/ds2man/basic-streaming/case2'}
OK?          : True
--------------
--------------
[Streaming Body]
DS

2

Man

The media type application/x-ndjson (Newline-Delimited JSON) is used when the server streams a sequence of JSON objects, each separated by a newline character (\n). This format is particularly suitable for streaming large datasets or real-time data, because each JSON object can be parsed individually as it arrives—without waiting for the entire response to complete.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async def fake_stream_ndjson_generator():
    for chunk in ["DS", "2", "Man"]:
        data = {
            "model": "gemma3:latest",
            "created_at": datetime.utcnow().isoformat() + "Z",
            "message": {"role": "assistant", "content": chunk},
            "done": False
        }
        await sleep(0.5)
        yield json.dumps(data, ensure_ascii=False) + "\n"
    
    done_data = {
        "model": "gemma3:latest",
        "created_at": datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
        "message": {
            "role": "assistant",
            "content": ""
        },
        "done": True
    }
    yield json.dumps(done_data, ensure_ascii=False) + "\n"

@router.get("/case3")
async def stream_case3():
    return StreamingResponse(fake_stream_ndjson_generator(), media_type="application/x-ndjson")

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
--------------
[Response Info]
Status       : 200 OK
URL          : http://127.0.0.1:7249/ds2man/basic-streaming/case3?key1=value1&key2=value2
Method       : GET
Content-Type : application/x-ndjson
Charset      : None
Cookies      : {}
Headers:
{'Content-Type': 'application/x-ndjson',
 'Date': 'Sun, 11 May 2025 15:16:59 GMT',
 'Server': 'uvicorn',
 'Transfer-Encoding': 'chunked',
 'myprocess-time': '0.0009999275207519531',
 'request-url': '/ds2man/basic-streaming/case3'}
OK?          : True
--------------
--------------
[Streaming Body]
{"model": "gemma3:latest", "created_at": "2025-05-11T15:16:59.661373Z", "message": {"role": "assistant", "content": "DS"}, "done": false}
{"model": "gemma3:latest", "created_at": "2025-05-11T15:17:00.179340Z", "message": {"role": "assistant", "content": "2"}, "done": false}
{"model": "gemma3:latest", "created_at": "2025-05-11T15:17:00.693325Z", "message": {"role": "assistant", "content": "Man"}, "done": false}
{"model": "gemma3:latest", "created_at": "2025-05-11T15:17:01.206333Z", "message": {"role": "assistant", "content": ""}, "done": true}
This post is licensed under CC BY 4.0 by the author.