Profiling and debugging
SQLAdmin does not ship a profiler or a debug toolbar, and does not wire one in for you. The tools below already work with it, and each one attaches to your application rather than to the admin. That is usually where you want it: SQLAdmin builds its own queries, and the requests you need to understand are your application's, not the admin's.
None of this requires changes to SQLAdmin. If you want the results inside the admin, asgi-profiler has a built-in SQLAdmin integration that adds its viewer as an admin page, behind your admin login.
Choosing a tool
| Tool | Answers | Runs in |
|---|---|---|
| asgi-profiler | Which request, which SQL, which N+1 — viewable in the admin | Development/Production |
| fastapi-debug-toolbar | Same, rendered into the page | Development |
| debug-toolbar | Same, plus N+1 flags, EXPLAIN, profiling |
Development |
| pyinstrument | Where the time goes outside SQL | Development |
| SQLAlchemy echo | What SQL ran, in order | Anywhere |
| py-spy | Why a live process is slow or stuck | Production |
The first four are development tools. Do not leave any of them enabled on a public deployment — see Security.
When the admin itself is slow
SQLAdmin eager-loads (selectinload) every relationship you list directly in
column_list or column_details_list, so adding Post.user to a list page costs one
extra query, not one per row. A few things are not covered by that:
-
Dotted paths and properties.
column_list = ["user.name"], or a@propertythat walks relationships, lazy-loads per row. On a ten-row page these took 12 and 22 queries respectively. Overridelist_query(ordetails_query) to load what they touch:from sqlalchemy import select from sqlalchemy.orm import selectinload class PostAdmin(ModelView, model=Post): column_list = [Post.id, "user.name", "author_company"] def list_query(self, request): return select(Post).options( selectinload(Post.user).selectinload(User.company) )With that override, both columns together cost 4 queries.
-
To-many relationships in
column_list. They are eager-loaded in full. A user with 500 posts renders all 500 into the list cell. - Relationship fields on the edit page. The select loads every row of the related
table.
form_ajax_refsfixes that; see Optimize relationship loading. - Relationships inside
__str__. Related objects are rendered after the session has closed, so a__str__that reaches through another relationship raisesDetachedInstanceErrorrather than running slowly. Keep__str__to columns on the model itself.
Use a profiler when you do not yet know which page or which query is at fault.
asgi-profiler
asgi-profiler records every request with the
SQL it issued and serves a browsable viewer. Repeated statements collapse into one row with
an xN badge, so a 500-row N+1 reads as a single line rather than 500 you have to scroll.
SQLAdmin integration
asgi-profiler ships asgi_profiler.contrib.sqladmin, which shows the profiler as a page
inside SQLAdmin, behind your authentication backend. See
SQLAdmin integration below.
pip install asgi-profiler
from asgi_profiler import install
from fastapi import FastAPI
from sqladmin import Admin
app = FastAPI()
admin = Admin(app, engine)
install(app) # viewer at /profiler
Install it on the parent application, not on the admin. Admin mounts itself onto your
app, so middleware on the parent sees every request, and your own routes are profiled
alongside it.
You do not need to hand it an engine. asgi-profiler hooks SQLAlchemy at the Engine class
level, so it captures queries from every engine in the process. The flip side is that it
cannot be scoped to a single engine.
SQLAdmin integration
asgi-profiler has a built-in SQLAdmin integration, asgi_profiler.contrib.sqladmin. Its
register function adds the viewer to your admin as a regular page, instead of serving it
on its own route:
from asgi_profiler import install
from asgi_profiler.contrib.sqladmin import register
admin = Admin(app, engine, authentication_backend=auth_backend)
profiler = install(app, mount_path=None) # record only, no standalone viewer
register(admin, profiler) # "Profiler" entry in the admin sidebar
This is the recommended setup when you already run the admin:
- The viewer goes through SQLAdmin's
expose, so it sits behind yourauthentication_backend. The standalone viewer has no authentication of its own. - With an authorization backend configured, the page also needs a
listgrant on its identity -- for custom pages,listmeans "may open this page". Without it the entry is hidden and the page returns403. - Requests to the admin are not recorded by default, so the history shows your
application's traffic rather than admin page views. Pass
profile_admin=Truewhen the admin itself is what you are debugging. mount_path=Noneskips the standalone viewer, so nothing is served outside the admin.
register also accepts name, icon and category to place the entry in the sidebar.
Options and storage
install(
app,
mount_path="/_perf", # default "/profiler"; None to skip the viewer
exclude_paths=["/healthz"],
slow_request_ms=500,
slow_query_ms=50,
capture_stacks=True, # which line issued the query
stack_depth=8,
authorize=lambda request: request.user.is_staff,
)
By default, captures live in a per-process ring buffer and vanish on restart. For captures that survive a reload, or that are shared across uvicorn workers:
from asgi_profiler import SQLiteStorage, install
install(app, storage=SQLiteStorage("profiler.db", max_requests=5000))
The file can then be read without running the app at all:
python -m asgi_profiler profiler.db # viewer on :8080
The standalone viewer has JSON twins — requests.json, summary.json,
statements.json — so a query-count regression can be asserted in CI rather than
noticed six months later.
Two caveats:
- Overhead is real. Roughly 20 µs per query, which on a 20-query endpoint is about a 70% increase in wall time. Use it to find the shape of a problem, not to measure absolute latency.
- Admin pages share route groups. With
profile_admin=True, SQLAdmin's routes are grouped by their pattern, such as/admin/{identity}/edit/{pk:path}, so edit pages for every model land in one group. Use the per-request view to tell them apart.
It is marked alpha. Treat it as a development tool.
Debug toolbars
A toolbar renders panels — SQL, timings, headers, templates — directly into the page you
are looking at. Adding one to Admin was proposed in
#1125. SQLAdmin documents them instead,
because a toolbar belongs on your application, which is what it would have been installed
on anyway.
There are two that work with SQLAdmin. Both install into a module named debug_toolbar, so
pick one — they cannot be installed side by side.
A toolbar needs an HTML response to inject itself into. It shows on SQLAdmin's list and
edit pages but tells you nothing about JSON endpoints such as
/admin/{identity}/ajax/lookup, or about your own API routes. asgi-profiler covers those.
fastapi-debug-toolbar
fastapi-debug-toolbar is the established FastAPI option:
pip install fastapi-debug-toolbar
from debug_toolbar.middleware import DebugToolbarMiddleware
from fastapi import FastAPI
app = FastAPI(debug=True)
app.add_middleware(
DebugToolbarMiddleware,
panels=["debug_toolbar.panels.sqlalchemy.SQLAlchemyPanel"],
)
The app must be constructed with debug=True or the toolbar does not render.
debug-toolbar
debug-toolbar
(docs) is an async-native toolbar with a
framework-agnostic core and adapters for Starlette, FastAPI and Litestar. Its SQLAlchemy
panel flags N+1 patterns and duplicate queries and can show EXPLAIN plans, and it adds
profiling (with flame graphs), memory and alerts panels on top of the usual set.
The repository is called debug-toolbar, but on PyPI it is published as
litestar-debug-toolbar; pip install debug-toolbar finds nothing. Install it with
the litestar extra even on FastAPI (see the caveats below):
pip install "litestar-debug-toolbar[fastapi,litestar]" sqlalchemy
from debug_toolbar.fastapi import FastAPIDebugToolbarConfig, setup_debug_toolbar
from fastapi import FastAPI
from sqladmin import Admin
app = FastAPI()
admin = Admin(app, engine)
setup_debug_toolbar(
app,
FastAPIDebugToolbarConfig(
enabled=settings.DEBUG,
extra_panels=["debug_toolbar.extras.advanced_alchemy.SQLAlchemyPanel"],
),
)
For a plain Starlette app, use debug_toolbar.starlette and StarletteDebugToolbarConfig
with the [starlette] extra instead. Past requests are browsable at /_debug_toolbar/.
Call it on the parent application, as above. Despite its module path, the SQLAlchemy panel
does not need Advanced Alchemy: it listens on SQLAlchemy's Engine class, so it picks up
any engine, sync or async, with only sqlalchemy installed.
Things to know before relying on it (checked against 0.4.2):
- It needs Litestar installed, even on FastAPI or Starlette. Without it, the toolbar is
injected into the page but its stylesheet and script return 500, because the Starlette
routes import from the Litestar adapter. Adding the
litestarextra is the workaround. - It does not require
debug=True, andenableddefaults toTrue. There is no built-in interlock, so driveenabledfrom a setting yourself. show_toolbar_callbackdoes not protect/_debug_toolbar/. It decides which requests are recorded and get the toolbar, but the history routes are open to anyone: a request the callback rejects can still read the headers and cookies of one it accepted, via/_debug_toolbar/api/requests.- It is marked alpha.
pyinstrument
pyinstrument answers a different question: not
what SQL ran, but where the Python time went. Reach for it when a page is slow and the
query count already looks reasonable — form scaffolding, column_formatters, or template
rendering over a large column_list can all cost more than the database does.
pip install pyinstrument
from fastapi import Request
from fastapi.responses import HTMLResponse
from pyinstrument import Profiler
PROFILING = True # drive this from settings
if PROFILING:
@app.middleware("http")
async def profile_request(request: Request, call_next):
if not request.query_params.get("profile"):
return await call_next(request)
profiler = Profiler(interval=0.001)
profiler.start()
await call_next(request)
profiler.stop()
return HTMLResponse(profiler.output_html())
Append ?profile=1 to any URL, in your app or the admin, to get a flame report instead of
the page.
The response is replaced by the report, so this works for reading a page's cost but not for profiling a form submission you also want to succeed.
SQLAlchemy echo
Before adding any dependency, you can see every statement that runs:
engine = create_engine(DATABASE_URL, echo=True)
Or, without touching the engine, through logging, which lets you keep it out of the way until you want it:
import logging
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
Crude, but it costs nothing and answers "how many queries does this page run" immediately.
Counting identical SELECT lines in the output is usually enough to confirm an N+1 before
you go looking for a nicer tool.
py-spy
Everything above needs a code change and is unsafe to leave enabled. When a deployed app is slow or wedged, py-spy attaches to a running process from the outside:
py-spy dump --pid 1234 # what is it doing right now
py-spy top --pid 1234 # live function-level view
py-spy record --pid 1234 -o profile.svg
No import, no middleware, no restart. This is the only option on this page that is
reasonable to point at production, and dump in particular is the fastest way to tell a
deadlock apart from a slow query.
For ongoing production visibility rather than one-off investigation, instrument with OpenTelemetry and its SQLAlchemy integration instead — that is telemetry, not profiling.
Scoping to the admin only
If you want a middleware on the admin and nowhere else, Admin accepts middleware for its
sub-application:
from starlette.middleware import Middleware
admin = Admin(
app,
engine,
middlewares=[Middleware(SomeProfilingMiddleware)],
)
These run for admin routes only. This is the wrong seam for asgi-profiler and the toolbars,
which expect the application they are installed on; for asgi-profiler, use register with
profile_admin=True instead.
Security
Profilers and toolbars expose SQL, bound parameters, request headers and sometimes session cookies. Next to an admin interface that is a particularly bad combination, since the data in those queries is usually the data you were protecting with authentication in the first place.
- Gate them on a setting and leave that setting off by default.
-
asgi-profiler's standalone viewer has no authentication of its own. Either show it inside the admin with
registerandmount_path=None, or passauthorize:install(app, authorize=lambda request: request.user.is_staff)authorizemay be async, so it can hit the database if your staff check needs to. asgi-profiler also redactsCookie,Authorizationand similar headers by default. -
fastapi-debug-toolbarrequiresdebug=True, which you should not be running in production for unrelated reasons. Treat that requirement as a safety interlock. debug-toolbarhas no such interlock, and its/_debug_toolbar/history is readable by anyone who can reach the app, whatevershow_toolbar_callbackreturns. Setenabledfrom a setting, and keep that path off any network you do not control.