Sub-applications, WSGI & GraphQL
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. All examples use Annotated where a parameter needs one, run on Python 3.10 or newer and were executed on
Python 3.13 with TestClient, except the root_path example, which needs a real Uvicorn 0.54 server because the test
client does not reproduce what a server does with the prefix. The WSGI examples use Flask 3.1, Django 6.1 and
a2wsgi 1.10; the GraphQL example uses Strawberry 0.328.
A FastAPI application is an ASGI application, and ASGI applications compose: any ASGI callable can be mounted under a
path prefix of another one, and the outer application hands everything below that prefix to the inner one. This one
mechanism covers four situations this page walks through, from the most to the least FastAPI-specific: an independent
FastAPI sub-application with its own docs, a plain Starlette (or any ASGI) application, a WSGI application such as
Flask or Django that you are gradually replacing, and a GraphQL endpoint next to your REST routes. The Bookshelf API
scenario is used throughout: a public API, an admin sub-application, an operations endpoint, a legacy Flask or Django
service being migrated, and a GraphQL view of the catalog. For splitting a single API across files with
APIRouter, which is not mounting, see
Project Structure & Layered Architecture.
Mounting, Routers or Separate Services
app.mount(path, application) and app.include_router(router) look similar and are very different.
include_router (an APIRouter) |
mount (a sub-application) |
|
|---|---|---|
Same application? |
Yes. The router’s operations are copied into the app. |
No. The sub-application is a separate ASGI app that receives every request below the mount path. |
OpenAPI and |
One shared schema and one docs page. |
Each application has its own |
Dependencies, |
Shared with the app. |
Independent. Each application has its own overrides. |
Exception handlers |
The app’s handlers apply. |
Only the sub-application’s own handlers apply. |
Middleware |
The app’s middleware wraps the router’s routes. |
The main app’s middleware also wraps the mount (it runs first); the sub-application’s middleware wraps only its own routes. |
Lifespan |
Run with the main lifespan. |
Not run when mounted (see Lifespan Is Not Run for Mounted Applications). |
Typical use |
Organizing one API by feature. |
A separately documented API (admin, versioned legacy, internal), a third-party ASGI app, a WSGI app, static files. |
If one consumer-facing contract and one docs page are what you want, use routers. Mount when the pieces genuinely have different contracts, different audiences or a different framework.
Mounting a FastAPI Sub-application
"Mounting" means attaching a completely independent application at a path; it handles everything under that path with
its own path operations. The pattern from the official documentation is a main application with its own operations and
a second FastAPI instance passed to mount:
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI(title="Bookshelf API")
@app.get("/books")
def list_books():
return [{"id": 1, "title": "Dune"}]
admin = FastAPI(title="Bookshelf Admin", version="1.0.0")
@admin.get("/stats")
def stats():
return {"books": 1, "reviews": 0}
app.mount("/admin", admin, name="admin")
client = TestClient(app)
print(client.get("/books").json())
print(client.get("/admin/stats").json())
print("main paths:", sorted(client.get("/openapi.json").json()["paths"]))
sub = client.get("/admin/openapi.json").json()
print("sub paths:", sorted(sub["paths"]), "servers:", sub.get("servers"))
print("main docs:", client.get("/docs").status_code, "sub docs:", client.get("/admin/docs").status_code)
html = client.get("/admin/docs").text
print("swagger url in sub docs:", "url: '/admin/openapi.json'" in html, "/admin/openapi.json" in html)
print("unknown under mount:", client.get("/admin/nope").status_code, client.get("/admin/nope").json())
print("redirect for /admin:", client.get("/admin", follow_redirects=False).status_code)
[{'id': 1, 'title': 'Dune'}]
{'books': 1, 'reviews': 0}
main paths: ['/books']
sub paths: ['/stats'] servers: [{'url': '/admin'}]
main docs: 200 sub docs: 200
swagger url in sub docs: True True
unknown under mount: 404 {'detail': 'Not Found'}
redirect for /admin: 307
What the run shows:
-
The main schema lists only
/booksand the admin schema only/stats; the admin operations are not in the main/docs. Open/admin/docsand/admin/redocfor the sub-application. This is the "separate docs" behaviour, and it is also why API clients and generators must be pointed at/admin/openapi.jsonexplicitly. -
Paths inside the sub-application do not repeat the prefix: it declares
/stats, the client calls/admin/stats. -
The sub-application’s OpenAPI document gets a
serversentry with the mount path, and its Swagger UI points at/admin/openapi.json, so "Try it out" works. FastAPI does this throughroot_path(seeroot_pathand Mounted Applications). -
The
name="admin"argument gives the mount a name, which is whatrequest.url_forneeds to build links into it. -
A request for
/admin(no trailing slash) is redirected with307to/admin/, and unknown paths under the mount are answered by the sub-application’s own 404. -
Each application has its own
title,version,description,docs_url,openapi_urland so on. The metadata options are covered in OpenAPI & Interactive Docs; to hide the sub-application’s docs, create it withdocs_url=None, redoc_url=None, openapi_url=None. -
Sub-applications can mount their own sub-applications; the
root_pathvalues accumulate.
What Is Separate and What Is Not
Because the sub-application is a different FastAPI instance, configuration does not leak in either direction, except
for the middleware that wraps the outside:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
class Teapot(Exception):
pass
def get_user():
return "real"
app = FastAPI(title="Bookshelf API")
admin = FastAPI(title="Bookshelf Admin")
@app.exception_handler(Teapot)
async def main_handler(request: Request, exc: Teapot):
return JSONResponse({"handled_by": "main"}, status_code=418)
@app.middleware("http")
async def tag_main(request: Request, call_next):
response = await call_next(request)
response.headers["X-Main"] = "1"
return response
@admin.middleware("http")
async def tag_admin(request: Request, call_next):
response = await call_next(request)
response.headers["X-Admin"] = "1"
return response
@app.get("/boom")
def main_boom():
raise Teapot()
@admin.get("/boom")
def admin_boom():
raise Teapot()
@admin.get("/missing")
def admin_missing():
raise HTTPException(404, "no such report")
@admin.get("/me")
def me(user: Annotated[str, Depends(get_user)]):
return {"user": user}
app.mount("/admin", admin)
c = TestClient(app, raise_server_exceptions=False)
print("main handler on main route:", c.get("/boom").status_code, c.get("/boom").json())
r = c.get("/admin/boom")
print("main handler on sub route :", r.status_code, r.text)
print("sub HTTPException:", c.get("/admin/missing").json())
app.dependency_overrides[get_user] = lambda: "from-main"
print("override on main app:", c.get("/admin/me").json())
admin.dependency_overrides[get_user] = lambda: "from-sub"
print("override on sub app :", c.get("/admin/me").json())
r = c.get("/admin/me")
print("sub route headers :", r.headers.get("x-main"), r.headers.get("x-admin"))
r = c.get("/boom")
print("main route headers:", r.headers.get("x-main"), r.headers.get("x-admin"))
main handler on main route: 418 {'handled_by': 'main'}
main handler on sub route : 500 Internal Server Error
sub HTTPException: {'detail': 'no such report'}
override on main app: {'user': 'real'}
override on sub app : {'user': 'from-sub'}
sub route headers : 1 1
main route headers: 1 None
-
Exception handlers are per application. A custom
Teapothandler registered onappproduced418for the main route and an unhandled500for the same exception raised in the sub-application. Register the handler on the sub-application too (a function you call on both is the usual way). Built-in handling ofHTTPExceptionand validation errors is installed on everyFastAPIinstance, so the sub-application’s404came back as normal JSON. See Status Codes & Error Handling. -
dependency_overridesare per application. Overridingget_useron the main app changed nothing inside the mounted one; the override had to be set onadmin. In tests, this is the thing to remember (see Testing), and the dependency tree itself is documented in Dependency Injection. -
Middleware nests. The
X-Mainheader was added to the sub-application’s routes as well, because the main app’s middleware stack sits outside the mount;X-Adminonly appeared on admin routes. Cross-cutting concerns that must cover everything (CORS, request IDs, compression, trusted hosts) belong on the main application; see Middleware, CORS & Proxies. -
Security is per application too. Each application builds its own OpenAPI schema, so a security scheme appears only in the docs of the application whose operations use it; share the same dependency function and use it in both.
-
Settings and shared resources. Nothing is shared automatically. Pass shared objects through module-level functions (a cached settings dependency) or through lifespan state; see Lifespan Is Not Run for Mounted Applications.
root_path and Mounted Applications
When you mount a sub-application, FastAPI communicates the mount path to it through the ASGI root_path mechanism.
That is how the sub-application’s docs know to load /admin/openapi.json and how url_for produces links with the
prefix. The same mechanism is used when the whole application sits behind a reverse proxy that strips a prefix such as
/api/v1; FastAPI combines the two prefixes, so nesting just works. The proxy side (forwarded headers, stripping,
--root-path) is covered in
Middleware, CORS & Proxies; here is how the two
interact. Run the app below with Uvicorn’s --root-path, which is what a stripping proxy needs:
from fastapi import FastAPI, Request
app = FastAPI(title="Bookshelf API")
admin = FastAPI(title="Admin")
@admin.get("/where")
def where(request: Request):
return {
"path": request.scope["path"],
"root_path": request.scope["root_path"],
# the router in the scope is the MAIN app's, so mount names are what resolve
"link": str(request.url_for("admin", path="/where")),
}
@app.get("/link")
def link(request: Request):
return {"root_path": request.scope["root_path"], "admin": str(request.url_for("admin", path="/where"))}
app.mount("/admin", admin, name="admin")
Source: FastAPI: Behind a Proxy
uvicorn e3:app --port 8321 --root-path /api/v1
# The proxy strips /api/v1 and forwards:
curl http://localhost:8321/admin/where
curl http://localhost:8321/link
curl http://localhost:8321/admin/openapi.json
curl -s http://localhost:8321/admin/docs | grep -o "url: '[^']*'"
{"path":"/api/v1/admin/where","root_path":"/api/v1/admin","link":"http://localhost:8321/api/v1/admin/where"}
{"root_path":"/api/v1","admin":"http://localhost:8321/api/v1/admin/where"}
The sub-application’s OpenAPI servers entry was [{"url": "/api/v1/admin"}], the main one’s [{"url": "/api/v1"}], and the
sub-application’s Swagger UI loaded /api/v1/admin/openapi.json: the mount path was appended to the proxy prefix.
Things this run revealed:
-
Uvicorn puts
root_pathin front of the request path it hands to the app (pathwas/api/v1/admin/wherealthough the client asked for/admin/where), and setsroot_pathto/api/v1; Starlette’sMountthen extends it to/api/v1/adminfor the sub-application. ATestClientdoes not do the prefixing, so a mounted app tested withroot_path=in the test client returns 404 for the stripped path. Test the prefix behaviour with a real server or by sending the full path. -
request.url_for("where")called inside the sub-application raisedNoMatchFound: the router stored in the request scope is the main application’s, so only names registered on the main application (such as the mount’sname) resolve. Build links to mounted routes withrequest.url_for("admin", path="/where"). -
The
root_pathconstructor argument (FastAPI(root_path="/api/v1")) works the same way as--root-pathfor mounted apps, but keep one source of truth for the prefix.
Lifespan Is Not Run for Mounted Applications
Startup and shutdown run only for the main application. A lifespan= on a mounted sub-application is ignored, and
the observed behaviour (mounted app, included router, and entering the sub-application’s lifespan from the parent) is
laid out in Lifespan in
Sub-applications and Routers. Put shared resources such as the database engine in the main lifespan and read them from
request.state, which the sub-application sees.
Mounting Starlette and Other ASGI Applications
FastAPI subclasses Starlette, so app.mount takes any ASGI application: a Starlette instance, a FastAPI instance,
another framework’s ASGI app, or an ASGI middleware chain. Starlette applications are the lightest option when the
mounted piece needs no validation and no OpenAPI, such as health probes, a metrics endpoint or robots.txt:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse
from starlette.routing import Mount, Route
async def health(request: Request):
return JSONResponse({"status": "ok", "root_path": request.scope["root_path"]})
async def robots(request: Request):
return PlainTextResponse("User-agent: *\nDisallow: /admin\n")
ops = Starlette(routes=[Route("/health", health), Route("/robots.txt", robots)])
app = FastAPI(title="Bookshelf API")
@app.get("/books")
def books():
return []
app.mount("/ops", ops, name="ops")
c = TestClient(app)
print(c.get("/ops/health").json())
print(c.get("/ops/robots.txt").text.splitlines())
print("in main OpenAPI:", sorted(c.get("/openapi.json").json()["paths"]))
print("starlette has no docs:", c.get("/ops/docs").status_code)
print("mount points:", [(type(r).__name__, getattr(r, "path", None)) for r in app.routes if isinstance(r, Mount)])
# a mount at "/" swallows everything declared after it
late = FastAPI()
late.mount("/", ops)
@late.get("/books")
def late_books():
return ["unreachable"]
lc = TestClient(late)
print("route declared after a root mount:", lc.get("/books").status_code)
# declared before, it wins
early = FastAPI()
@early.get("/books")
def early_books():
return ["reachable"]
early.mount("/", ops)
ec = TestClient(early)
print("route declared before:", ec.get("/books").json(), "| mount still serves:", ec.get("/health").json()["status"])
{'status': 'ok', 'root_path': '/ops'}
['User-agent: *', 'Disallow: /admin']
in main OpenAPI: ['/books']
starlette has no docs: 404
mount points: [('Mount', '/ops')]
route declared after a root mount: 404
route declared before: ['reachable'] | mount still serves: ok
-
Mounted Starlette routes never appear in OpenAPI, and have no automatic docs or validation; they receive a Starlette
Requestand return Starlette responses (imported fromstarlettedirectly, or fromfastapi.responses, which re-exports them). -
Order matters. Routes are matched in the order they were added. A mount at
"/"matches every path, so a path operation declared after it is unreachable (404for/books); declared before, the path operation wins and the mount serves everything else. Mount catch-all applications last. -
The same mechanism serves static files:
app.mount("/static", StaticFiles(directory="static"), name="static"), with its own page in Static Files, Templates & Frontend. -
Mounting is how Starlette composes applications; its
Mount,RouteandHostclasses are documented in the Starlette routing guide. FastAPI’s ownapp.mountis the inherited method. -
Mounting a non-FastAPI app means FastAPI’s dependency injection,
response_modeland error format do not apply inside it; errors there are that application’s own responses, not FastAPI’s JSONdetailformat.
Embedding Flask or Django with WSGIMiddleware
WSGI applications (Flask, Django, Pyramid, Bottle) are synchronous and speak a different protocol (see
ASGI vs WSGI, and ASGI Servers), so they cannot be mounted
directly. An adapter translates between the two. The official documentation currently recommends the a2wsgi package.
For Flask itself, see the site’s Python Flask section.
pip install a2wsgi flask
import warnings
from a2wsgi import WSGIMiddleware
from fastapi import FastAPI
from fastapi.testclient import TestClient
from flask import Flask, jsonify, request
legacy = Flask(__name__)
@legacy.route("/")
def index():
name = request.args.get("name", "World")
return jsonify(message=f"Hello, {name} from Flask!", script_root=request.script_root, path=request.path)
@legacy.route("/books/<int:book_id>")
def book(book_id: int):
return jsonify(id=book_id, title="Dune", via="flask")
app = FastAPI(title="Bookshelf API")
@app.get("/v2/books/{book_id}")
def read_book(book_id: int):
return {"id": book_id, "title": "Dune", "via": "fastapi"}
app.mount("/v1", WSGIMiddleware(legacy))
c = TestClient(app)
print(c.get("/v1/?name=Ada").json())
print(c.get("/v1/books/7").json())
print(c.get("/v2/books/7").json())
print("in OpenAPI:", sorted(c.get("/openapi.json").json()["paths"]))
print("flask 404:", c.get("/v1/nope").status_code, c.get("/v1/nope").headers["content-type"])
print("fastapi 404:", c.get("/nope").json())
print(type(WSGIMiddleware(legacy)).__module__)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
from fastapi.middleware.wsgi import WSGIMiddleware as Old # noqa: F401
print("old import warns:", [(w.category.__name__, str(w.message)[:90]) for w in caught])
{'message': 'Hello, Ada from Flask!', 'path': '/', 'script_root': '/v1'}
{'id': 7, 'title': 'Dune', 'via': 'flask'}
{'id': 7, 'title': 'Dune', 'via': 'fastapi'}
in OpenAPI: ['/v2/books/{book_id}']
flask 404: 404 text/html; charset=utf-8
fastapi 404: {'detail': 'Not Found'}
a2wsgi.wsgi
old import warns: [('StarletteDeprecationWarning', 'starlette.middleware.wsgi is deprecated and will be removed in a future release. Please re')]
What to take from it:
-
Every request below
/v1is handled by Flask; everything else by FastAPI. Flask’s routes are invisible to OpenAPI and/docs(only/v2/books/{book_id}is listed), and Flask answers its own 404 in HTML. -
WSGI sees the mount prefix as
SCRIPT_NAMEand the rest asPATH_INFO(verified with a bare WSGI function:SCRIPT_NAME='/v1',PATH_INFO='/books/7'). Flask exposes the prefix asrequest.script_rootand buildsurl_forlinks with it, so redirects and generated URLs stay under/v1. -
The import changed.
fastapi.middleware.wsgi.WSGIMiddlewareis only a re-export of Starlette’sstarlette.middleware.wsgi.WSGIMiddleware, which Starlette deprecated in favour of a2wsgi (release 0.19.0, 2022-03-09) and which in the installed Starlette 1.7 emits aStarletteDeprecationWarningon import saying it "will be removed in a future release". The FastAPI documentation now importsWSGIMiddlewarefroma2wsgiand says the usage is unchanged. Usefrom a2wsgi import WSGIMiddlewarein new code, and replace the old import when you upgrade. -
a2wsgi.WSGIMiddleware(app, workers=10, send_queue_size=10): the synchronous WSGI app runs in a pool of worker threads, so it never blocks the event loop, andworkersbounds how many WSGI requests run at once.
Django
For Django itself — projects, the ORM, views, the admin and deployment — see the site’s Django section; this subsection only covers mounting a Django WSGI app inside FastAPI.
Django exposes its WSGI callable through get_wsgi_application(); mount that instead of a Flask object. A minimal
project configured in code (a real project does this in settings.py and wsgi.py, with the secret key read from
the environment):
import os
import warnings
from a2wsgi import WSGIMiddleware
from fastapi import FastAPI
from fastapi.testclient import TestClient
# A minimal Django project configured in code, so the example is a single file.
import django
from django.conf import settings
settings.configure(
DEBUG=False,
ALLOWED_HOSTS=["testserver"],
ROOT_URLCONF=__name__,
SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "dev-only-not-a-real-key"),
)
django.setup()
from django.core.wsgi import get_wsgi_application
from django.http import JsonResponse
from django.urls import path
def authors(request):
return JsonResponse({"authors": ["Herbert", "Austen"], "script_prefix": request.META.get("SCRIPT_NAME")})
urlpatterns = [path("authors/", authors)]
app = FastAPI(title="Bookshelf API")
app.mount("/legacy", WSGIMiddleware(get_wsgi_application()))
c = TestClient(app, base_url="http://testserver")
print(c.get("/legacy/authors/").json())
Source: Django: How to deploy with WSGI
{'authors': ['Herbert', 'Austen'], 'script_prefix': '/legacy'}
Django derives its URL prefix from SCRIPT_NAME, so the mount path (/legacy) is picked up without configuration.
Static files, ALLOWED_HOSTS, database connections and the ORM stay Django’s business; keep Django’s settings module
configured the way it was, and mount Django’s static files separately with StaticFiles if you no longer serve them
through a WSGI server.
A Migration Strategy: Strangle the Legacy App
The mount is the first step of the classic strangler fig migration. FastAPI becomes the front door, the legacy application keeps serving everything it already did, and endpoints move over one at a time:
-
Step 1 — mount. Mount the Flask or Django app at
/or a prefix, and run the whole thing under an ASGI server. Nothing changes for clients. -
Step 2 — migrate route by route. Add new endpoints, and re-implement migrated ones, as FastAPI path operations declared before the mount (order matters, see Mounting Starlette and Other ASGI Applications). A migrated route takes precedence; unmigrated ones fall through to the legacy app.
-
Step 3 — authenticate at the front door. FastAPI dependencies never run for a mounted app, so protect the mount with middleware on the main application (the example also shows the worker threads):
import os
import threading
import time
from a2wsgi import WSGIMiddleware
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from flask import Flask, jsonify
LEGACY_API_KEY = os.environ.get("LEGACY_API_KEY", "dev-only-key")
legacy = Flask(__name__)
threads: set[str] = set()
@legacy.route("/slow")
def slow():
threads.add(threading.current_thread().name)
time.sleep(0.3)
return jsonify(ok=True)
app = FastAPI(title="Bookshelf API")
@app.middleware("http")
async def protect_legacy(request: Request, call_next):
# FastAPI dependencies never run for a mounted WSGI app, so guard it here
if request.url.path.startswith("/v1") and request.headers.get("x-api-key") != LEGACY_API_KEY:
return JSONResponse({"detail": "Missing or invalid API key"}, status_code=401)
return await call_next(request)
app.mount("/v1", WSGIMiddleware(legacy, workers=4))
c = TestClient(app)
print(c.get("/v1/slow").status_code, c.get("/v1/slow").json())
print(c.get("/v1/slow", headers={"X-API-Key": LEGACY_API_KEY}).json())
import concurrent.futures as cf
start = time.perf_counter()
with cf.ThreadPoolExecutor(4) as ex:
list(ex.map(lambda _: c.get("/v1/slow", headers={"X-API-Key": LEGACY_API_KEY}), range(4)))
elapsed = time.perf_counter() - start
print("4 parallel slow requests took less than 4 x 0.3 s:", elapsed < 1.0)
print("handled on worker threads, not the event loop thread:", all(t != "MainThread" for t in threads), len(threads) >= 1)
Source: FastAPI: Advanced Middleware
401 {'detail': 'Missing or invalid API key'}
{'ok': True}
4 parallel slow requests took less than 4 x 0.3 s: True
handled on worker threads, not the event loop thread: True True
-
Step 4 — retire. Delete legacy routes as the replacements get traffic; when the mount serves nothing, remove it and the dependency on a2wsgi.
Points specific to the WSGI bridge:
-
Concurrency. Each WSGI request occupies one worker thread for its whole duration (the four slow requests above ran in parallel, on threads other than the event loop’s). Size
workersand the Uvicorn settings to the legacy app’s blocking behaviour, and never call the legacy code directly from anasync defpath operation: the thread hand-off is what the adapter provides. See Async & Concurrency. -
Streaming and WebSockets. WSGI has no WebSocket support and a poor fit for long-lived streaming; keep those in FastAPI (WebSockets).
-
Sessions and authentication implemented by the legacy framework (cookies signed with its secret key) keep working for the mounted app, but are invisible to FastAPI. Decide which side owns identity before routing traffic through both, and prefer a single token format that both can verify (see OAuth2 Password Flow & JWT).
-
Data. Sharing one database between the two frameworks is the usual interim state; agree on who owns migrations.
-
Deployment. The combined application is one ASGI process; deploy it as described in Deployment.
GraphQL
Because FastAPI is ASGI, any GraphQL library with ASGI support integrates, and GraphQL and ordinary path operations
can live in the same application. The official how-to lists these libraries: Strawberry, Ariadne, Tartiflette (through
Tartiflette ASGI) and Graphene (through starlette-graphene3), and recommends Strawberry because its type-annotation
design is the closest to FastAPI’s. The how-to’s integration is a GraphQLRouter included like any router:
import strawberry
from fastapi import FastAPI
from fastapi.testclient import TestClient
from strawberry.fastapi import GraphQLRouter
@strawberry.type
class Book:
id: int
title: str
BOOKS = [Book(id=1, title="Dune"), Book(id=2, title="Emma")]
@strawberry.type
class Query:
@strawberry.field
def books(self) -> list[Book]:
return BOOKS
schema = strawberry.Schema(query=Query)
graphql_app = GraphQLRouter(schema)
app = FastAPI(title="Bookshelf API")
@app.get("/books/{book_id}")
def rest_book(book_id: int):
return {"id": book_id}
app.include_router(graphql_app, prefix="/graphql")
c = TestClient(app)
r = c.post("/graphql", json={"query": "{ books { id title } }"})
print(r.status_code, r.json())
print("REST still works:", c.get("/books/1").json())
print("in OpenAPI:", sorted(c.get("/openapi.json").json()["paths"]))
r = c.get("/graphql", headers={"accept": "text/html"})
print("GraphiQL:", r.status_code, r.headers["content-type"])
Source: FastAPI: GraphQL
200 {'data': {'books': [{'id': 1, 'title': 'Dune'}, {'id': 2, 'title': 'Emma'}]}}
REST still works: {'id': 1}
in OpenAPI: ['/books/{book_id}', '/graphql']
GraphiQL: 200 text/html; charset=utf-8
-
The GraphQL endpoint is a router, not a mount: it appears in the OpenAPI schema as a single
/graphqlentry, REST operations next to it are unaffected, and FastAPI dependencies can be injected into resolvers through Strawberry’scontext_getter. A browserGETto/graphqlserves the GraphiQL IDE. -
Install with
pip install "strawberry-graphql[fastapi]". Installing it movedgraphql-coreto 3.3 in the test environment, which can conflict with tools that pingraphql-core<3.3(such ashypothesis-graphqlused by Schemathesis); keep the GraphQL stack in the environment of the service that serves it. -
Evaluate whether GraphQL is the right tool before adding it: the how-to itself points out it has advantages and disadvantages compared with regular web APIs. The trade-offs are in When to Use GraphQL or REST.
-
Ariadne (schema-first) mounts as an ASGI application with
app.mount, which is the mounting mechanism of the Mounting Starlette and Other ASGI Applications section.
This page deliberately stops here. Everything else about GraphQL on Python is documented once, in the GraphQL section, and not repeated:
| Topic | Page |
|---|---|
Choosing between Strawberry, Ariadne and graphql-core, installing and running a minimal server |
|
Types, resolvers, |
|
Enums, interfaces, unions, input types, permission classes, DataLoaders, errors, pagination |
|
Schema-first with Ariadne mounted under FastAPI or Starlette |
Where Older Guides Are Outdated, and What Changed Recently
-
from fastapi.middleware.wsgi import WSGIMiddleware. Older tutorials and the early FastAPI docs use it. It is a re-export of the Starlette class that Starlette deprecated in 0.19.0 (2022-03-09) in favour of a2wsgi, and the installed Starlette 1.7 warns that it will be removed. The FastAPI docs now import it froma2wsgi(a documentation change recorded in the FastAPI release notes); the call is the same. -
GraphQLAppfrom Starlette. Very old FastAPI GraphQL guides mountedstarlette.graphql.GraphQLAppwith Graphene. Starlette deprecated it in 0.15.0 and removed it in 0.17.0 (2021); the FastAPI how-to points tostarlette-graphene3for code that used it and to Strawberry for new code. -
openapi_prefix. Older sub-application and proxy guides passFastAPI(openapi_prefix="/api/v1"); the parameter is deprecated in favour ofroot_path(the constructor says so), and mounted applications get the right prefix automatically. -
"Mounting is how you version an API." Guides that mount
v1andv2as separateFastAPIinstances do get separate docs, but at the price of separate middleware, handlers, overrides and lifespans.APIRouter(prefix="/v1")plus tags keeps one application; mount only when the contracts really must be separate. -
Lifespan in sub-applications. Since lifespan context managers arrived (FastAPI 0.93.0, 2023-03-07), startup and shutdown code in a mounted sub-application is not run; run it from the parent (Lifespan Is Not Run for Mounted Applications).
-
Starlette 1.0. FastAPI supports Starlette 1.0.0 and later since 0.133.0 (2026-02-24); the mounting API described here (
mount,Mount,root_path) is unchanged. In Starlette 1.x theTestClientalso warns when used withhttpx(the warning suggestshttpx2); see Testing. -
The collected list of changes since older tutorials is in What’s Changed & Migration.
Pitfalls
-
Expecting mounted operations in the main
/docsor/openapi.json. They are in the sub-application’s own docs; link to them from the main description if users must find them. -
Registering exception handlers, middleware or
dependency_overridesonly on the main app and being surprised they do not apply inside the mount. -
Putting startup code in a mounted app’s
lifespan(never runs), or readingrequest.url_for("route")for a sub-application route from inside the sub-application (use the mount’s name withpath=). -
Declaring a path operation after
mount("/", …); the mount shadows it. -
Using a
TestClient(root_path=…)to test mounts behind a proxy prefix and getting 404s; the real server prepends the prefix to the path, the test client does not. -
Importing the deprecated
fastapi.middleware.wsgi, and running a blocking WSGI app by calling it from anasync defpath operation instead of mounting it through the adapter. -
Leaving the legacy mount unauthenticated because FastAPI dependencies do not run there; guard it with middleware.
-
Leaving Django
DEBUGon orALLOWED_HOSTSempty when exposing it through FastAPI, and hard-coding its secret key; read both from settings. -
Treating GraphQL as free: it bypasses per-route response models and status codes, so add query-depth and cost limits, and authorize in resolvers (the GraphQL pages above cover this).
-
Mounting many sub-applications "for organization" when
APIRouterwould do.