Skip to main content

msgspec-fastapi

An experimental FastAPI router that uses msgspec for JSON request bodies and responses. In one benchmark service, moving from Pydantic models to msgspec Structs with this router increased throughput by 68% on a route with a large JSON body and by 11% to 13% on database-backed routes; see the Benchmarks section below.

This is a prototype. Its API may change, and it relies on private FastAPI internals, so it is pinned to FastAPI 0.142.2 to 0.143.x. It is a personal project, not part of msgspec; feedback is welcome in the issues.

Installation

pip install msgspec-fastapi

Usage

import msgspec
from fastapi import FastAPI
from msgspec_fastapi import MsgspecRouter, install_openapi


class City(msgspec.Struct):
    name: str
    population: int


router = MsgspecRouter()


@router.post('/cities')
async def create_city(city: City) -> City:
    return city


app = FastAPI()
app.include_router(router)
install_openapi(app, router)

Benchmarks

The router was measured in a small reference-data service on a cloud VM with dedicated vCPUs (October 2026). Each row compares the same service on FastAPI with Pydantic models against the same service with msgspec Structs and MsgspecRouter. The database queries (SQLAlchemy ORM), event loop and server settings are the same on both sides, and there is no cache.

Request Requests per second RPS change CPU time per response CPU change
POST /cities/validate, a JSON body of 100 cities (23 KB), no database 1,610 → 2,703 +67.9% [+67.0%, +68.7%] 621 → 370 µs -40.4% [-40.7%, -40.1%]
GET /cities?limit=100, a page of 100 cities read from PostgreSQL 514 → 569 +10.7% [+9.9%, +11.5%] 1,944 → 1,757 µs -9.7% [-10.3%, -9.0%]
POST /cities/search, small JSON filter bodies, a page of 100 cities 447 → 505 +12.9% [+12.1%, +13.7%] 2,237 → 1,981 µs -11.4% [-12.1%, -10.8%]

The first route returns a constant 16-byte response ({"accepted":100}), so the difference on this route comes mostly from request decoding and validation, and the gain is largest there. On routes that read from a database, API CPU time per response falls by about 190 to 260 µs out of roughly 2 ms, so the relative gain is smaller.

In the same service, setting response_model=None on the GET route made no detectable difference in throughput with MsgspecRouter (0.0% [-1.0%, +1.0%]). This is consistent with an instance of exactly the declared Struct class being encoded without revalidation.

The benchmark sources, the data preparation scripts and the results of all 14 comparisons, including direct asyncpg queries without the ORM, uvloop, a Redis cache, Litestar and Go, are in world-directory-bench.

How it was measured
  • Host: a cloud VM with 6 dedicated vCPUs (AMD EPYC-Rome, KVM), Ubuntu 24.04. The API ran as one uvicorn worker (h11, asyncio) pinned to one core; PostgreSQL and the load generator ran on other cores.
  • Data and database: 224,578 cities, 55,246 division records, 501 country records and 86,185 airports from GeoNames and OurAirports, about 231 MB in PostgreSQL 18.6 including indexes. The PostgreSQL data directory was on tmpfs (in memory). Database access went through the SQLAlchemy ORM with the asyncpg driver.
  • Software: CPython 3.14.7, FastAPI 0.142.2, Pydantic 2.13.5, msgspec 0.22.0, SQLAlchemy 2.1.3 (ORM), asyncpg 0.31.0, uvicorn 0.54.0. The router code on the measured paths was the same as msgspec-fastapi 0.0.1.
  • Load: a Go generator with 20 closed-loop clients, checking every response body. The numbers are throughput at saturation of the single API core. The GET mix sends 9 of 10 requests to one page and every 10th to a page that is not repeated within the run.
  • Each comparison is 8 blocks in ABBA or BAAB order, so each block measures both versions twice; every run starts a fresh process with a 30 s warmup and a 60 s measurement. The percentages are computed from the mean of the per-block log ratios, with a nominal 95% t interval over the 8 blocks. The requests per second values are the geometric means of each side, and the CPU values are the arithmetic means.
  • CPU time per response is the CPU time of the API container in the measurement window divided by the number of checked responses. Because the API core was saturated, it is close to the inverse of throughput.

Things to keep in mind:

  • On the control side, FastAPI parses the body with json.loads and then validates it with Pydantic, which is FastAPI's standard path.
  • The GET and search rows also include changes outside the router: database rows are converted to msgspec Structs instead of Pydantic models and, for GET, the query model is msgspec instead of Pydantic, so they measure a migration, not the router alone.
  • Both sides check the same constraints, including custom per-record checks written in Python. Results for other schemas and other routes will differ.
  • This is one series on one machine. The host slowed down near the end of the series: a static control was about 10% slower after the series than before it. The blocks of all comparisons ran in 8 rounds, and the lasting slowdown began during the last round. Leaving out any single round, the last one included, changes each estimate by less than 0.32 percentage points. Compare the relative effects rather than the absolute numbers.

What it does

  • A msgspec body parameter (a Struct or another type msgspec can decode) is decoded from the raw request bytes by a msgspec.json.Decoder built once at route registration. A body with a non-JSON content type returns 415, malformed JSON or JSON nested too deeply returns 400, and a missing or invalid body returns 422. The 415 and 400 responses are sent before dependencies run; a 422 is sent after them, as in FastAPI, so a failing dependency such as authentication takes precedence. Bodies are decoded in msgspec strict mode: for example, the JSON string "1" is not accepted for an int field, unlike FastAPI with Pydantic.
  • A body without a content type follows FastAPI's strict_content_type setting: it is rejected with 415 by default and parsed as JSON when the setting is False. FastAPI returns 422 in the first case.
  • A msgspec response type, from the return annotation or response_model, is encoded with msgspec. An instance of exactly the declared Struct class is encoded without revalidation, as FastAPI does for a Pydantic response_model; other values are validated against the declared type. Nested values and type parameters of a trusted instance are not checked. A NaN or infinite value in a float field is encoded as null unless a constraint rejects it. In a Decimal field it is encoded as a string, while FastAPI with Pydantic rejects non-finite Decimal values.
  • Responses of routes without a return annotation or response_model, or with -> None, are also encoded with msgspec, so the output can differ from FastAPI's jsonable_encoder (for example, bytes are base64-encoded and Decimal is encoded as a string).
  • Path, query, header and cookie parameters keep FastAPI extraction, aliases and defaults, but are converted with msgspec.convert(strict=False) instead of Pydantic, so coercion differs for some inputs (for example, " 1.5" is rejected for float and "nan" is accepted for Decimal). Parameter types must be supported by msgspec; Pydantic-specific types such as AnyUrl raise TypeError at route registration.
  • install_openapi adds the msgspec JSON schemas to the OpenAPI document. It is required for a valid document: call it once after including the routers, passing every MsgspecRouter that is not included directly in another MsgspecRouter.
  • A Pydantic body or response model is left to FastAPI; the route parameters are still validated with msgspec.

Limitations

  • Only one msgspec body parameter per route, and it cannot be combined with other FastAPI body fields. Only JSON bodies are supported: application/json, application/*+json, or a request without a content type when strict_content_type is False. Form() and File() parameters are left to FastAPI.
  • When both the parameters and the body are invalid, only the parameter errors are returned; FastAPI returns both.
  • Structs, alone or inside containers, are not supported as path, query, header or cookie parameters, or as Form() and File() parameters.
  • response_model_include, response_model_exclude, response_model_exclude_* and response_model_by_alias=False are rejected for typed msgspec responses.

Development

uv run --group test pytest

Metadata

Release files for msgspec-fastapi 0.0.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for msgspec-fastapi 0.0.2
File Size Uploaded Provenance
msgspec_fastapi-0.0.2.tar.gz 19.6 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for msgspec-fastapi 0.0.2
File Interpreter ABI Platform
msgspec_fastapi-0.0.2-py3-none-any.whl Python 3 none any Details

Total release size: 35.5 kB

Release files / msgspec_fastapi-0.0.2.tar.gz

Download URL msgspec_fastapi-0.0.2.tar.gz
Size 19.6 kB
Tags Source
SHA-256 checksum
How to use checksums
ec961d8f38e4847e02e9ae09956c4a319e0bf934af7e8caaf20cce7b20d044c2
BLAKE2b-256 checksum
How to use checksums
8393f3dc696ed76a143da3f500ad78f1bd7efb2276e5cc3879c897688b2b8f8e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release files / msgspec_fastapi-0.0.2-py3-none-any.whl

Download URL msgspec_fastapi-0.0.2-py3-none-any.whl
Size 15.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
15dbb8bd592a1bf94b273a8ce9b857ccbee747046c170bc5630359fbeaf02f48
BLAKE2b-256 checksum
How to use checksums
1cf958d2d353988fd3ccdd3402bfa9f6650b05b873eb20c4fe9debb069b38dca
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Security for msgspec-fastapi 0.0.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for msgspec_fastapi-0.0.2.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page