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.

Table 1. Composition tools compared
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 /docs

One shared schema and one docs page.

Each application has its own /openapi.json and its own /docs and /redoc; the main schema does not list the mounted operations.

Dependencies, dependency_overrides

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 /books and the admin schema only /stats; the admin operations are not in the main /docs. Open /admin/docs and /admin/redoc for the sub-application. This is the "separate docs" behaviour, and it is also why API clients and generators must be pointed at /admin/openapi.json explicitly.

  • 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 servers entry with the mount path, and its Swagger UI points at /admin/openapi.json, so "Try it out" works. FastAPI does this through root_path (see root_path and Mounted Applications).

  • The name="admin" argument gives the mount a name, which is what request.url_for needs to build links into it.

  • A request for /admin (no trailing slash) is redirected with 307 to /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_url and so on. The metadata options are covered in OpenAPI & Interactive Docs; to hide the sub-application’s docs, create it with docs_url=None, redoc_url=None, openapi_url=None.

  • Sub-applications can mount their own sub-applications; the root_path values 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 Teapot handler registered on app produced 418 for the main route and an unhandled 500 for 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 of HTTPException and validation errors is installed on every FastAPI instance, so the sub-application’s 404 came back as normal JSON. See Status Codes & Error Handling.

  • dependency_overrides are per application. Overriding get_user on the main app changed nothing inside the mounted one; the override had to be set on admin. In tests, this is the thing to remember (see Testing), and the dependency tree itself is documented in Dependency Injection.

  • Middleware nests. The X-Main header was added to the sub-application’s routes as well, because the main app’s middleware stack sits outside the mount; X-Admin only 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")
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_path in front of the request path it hands to the app (path was /api/v1/admin/where although the client asked for /admin/where), and sets root_path to /api/v1; Starlette’s Mount then extends it to /api/v1/admin for the sub-application. A TestClient does not do the prefixing, so a mounted app tested with root_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 raised NoMatchFound: the router stored in the request scope is the main application’s, so only names registered on the main application (such as the mount’s name) resolve. Build links to mounted routes with request.url_for("admin", path="/where").

  • The root_path constructor argument (FastAPI(root_path="/api/v1")) works the same way as --root-path for 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 Request and return Starlette responses (imported from starlette directly, or from fastapi.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 (404 for /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, Route and Host classes are documented in the Starlette routing guide. FastAPI’s own app.mount is the inherited method.

  • Mounting a non-FastAPI app means FastAPI’s dependency injection, response_model and error format do not apply inside it; errors there are that application’s own responses, not FastAPI’s JSON detail format.

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 /v1 is 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_NAME and the rest as PATH_INFO (verified with a bare WSGI function: SCRIPT_NAME='/v1', PATH_INFO='/books/7'). Flask exposes the prefix as request.script_root and builds url_for links with it, so redirects and generated URLs stay under /v1.

  • The import changed. fastapi.middleware.wsgi.WSGIMiddleware is only a re-export of Starlette’s starlette.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 a StarletteDeprecationWarning on import saying it "will be removed in a future release". The FastAPI documentation now imports WSGIMiddleware from a2wsgi and says the usage is unchanged. Use from a2wsgi import WSGIMiddleware in 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, and workers bounds 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())
{'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)
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 workers and the Uvicorn settings to the legacy app’s blocking behaviour, and never call the legacy code directly from an async def path 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"])
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 /graphql entry, REST operations next to it are unaffected, and FastAPI dependencies can be injected into resolvers through Strawberry’s context_getter. A browser GET to /graphql serves the GraphiQL IDE.

  • Install with pip install "strawberry-graphql[fastapi]". Installing it moved graphql-core to 3.3 in the test environment, which can conflict with tools that pin graphql-core<3.3 (such as hypothesis-graphql used 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:

Table 2. Where to read about Python GraphQL
Topic Page

Choosing between Strawberry, Ariadne and graphql-core, installing and running a minimal server

Getting started with Python GraphQL

Types, resolvers, GraphQLRouter, context_getter with FastAPI dependencies, GraphiQL, subscriptions

Strawberry with FastAPI

Enums, interfaces, unions, input types, permission classes, DataLoaders, errors, pagination

Strawberry schema and features

Schema-first with Ariadne mounted under FastAPI or Starlette

Ariadne schema-first

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 from a2wsgi (a documentation change recorded in the FastAPI release notes); the call is the same.

  • GraphQLApp from Starlette. Very old FastAPI GraphQL guides mounted starlette.graphql.GraphQLApp with Graphene. Starlette deprecated it in 0.15.0 and removed it in 0.17.0 (2021); the FastAPI how-to points to starlette-graphene3 for code that used it and to Strawberry for new code.

  • openapi_prefix. Older sub-application and proxy guides pass FastAPI(openapi_prefix="/api/v1"); the parameter is deprecated in favour of root_path (the constructor says so), and mounted applications get the right prefix automatically.

  • "Mounting is how you version an API." Guides that mount v1 and v2 as separate FastAPI instances 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 the TestClient also warns when used with httpx (the warning suggests httpx2); see Testing.

  • The collected list of changes since older tutorials is in What’s Changed & Migration.

Pitfalls

  • Expecting mounted operations in the main /docs or /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_overrides only 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 reading request.url_for("route") for a sub-application route from inside the sub-application (use the mount’s name with path=).

  • 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 an async def path 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 DEBUG on or ALLOWED_HOSTS empty 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 APIRouter would do.