Skip to main content

How it compares

There are three common ways to isolate tenants in a FastAPI/SQLAlchemy application. They differ in one dimension that matters more than all the others: where enforcement lives.

Manual WHERE tenant_id = …with_loader_criteriafastapi-rls (PostgreSQL RLS)
Enforcement layerEvery call siteSQLAlchemy ORM sessionThe database itself
Covers ORM queriesOnly if you rememberYesYes
Covers raw SQL (text(…))Only if you rememberNoYes
Covers non-ORM / Core statementsOnly if you rememberPartially¹Yes
Covers other clients (psql, BI tools, cron jobs)²NoNoYes
Covers INSERT/UPDATE writesOnly if you rememberRead-focused¹Yes (WITH CHECK)
Survives a forgotten filterNo — that's the leakMostlyYes
Setup costNone upfront, unbounded ongoingLowMigration + role setup
Requires PostgreSQLNoNoYes

¹ with_loader_criteria hooks SQLAlchemy's ORM execution events. It's a solid mechanism for ORM SELECTs, but statements that bypass ORM entity resolution — textual SQL, some Core constructs, bulk operations depending on configuration — bypass the filter too, silently.

² Any connection using the same non-superuser role is constrained by RLS, including ones that never import your application code.

The failure mode each one has

Manual WHERE clauses fail one forgotten filter at a time. The cost isn't writing them — it's that correctness depends on every current and future developer remembering, on every query, forever. Code review is your only enforcement mechanism.

with_loader_criteria centralizes the filter, which is a real improvement. But it enforces in the application process: raw SQL escapes it, other services touching the same database escape it, and a developer can disable it per-query. If your threat model is "we might write a bad query", it helps; if it's "no code path may ever return another tenant's rows", it can't make that guarantee.

PostgreSQL RLS moves the predicate into the database. A query with no tenant context returns no rows — not all rows. The trade-offs are real, though: you're committed to PostgreSQL, you must connect as a non-superuser role (superusers and BYPASSRLS roles skip RLS entirely — see the security model), and policies add a predicate to every query on protected tables, which you should index for.

What about other libraries?

  • Permission libraries for FastAPI (e.g. fastapi-permissions) authorize requests in application code — useful, but orthogonal: they decide whether an endpoint runs, not which rows a query can see.
  • Tenancy libraries that filter at the ORM/session layer share with_loader_criteria's enforcement boundary: application code in, raw SQL and external clients out.
  • django-rls and flask-rls are sibling projects by the same author, applying the same database-enforced approach to Django and Flask.

When not to use fastapi-rls

Honest cases where the alternatives win:

  • You're not on PostgreSQL. RLS as implemented here is PostgreSQL-specific.
  • Single-tenant app, no per-user row scoping. You don't have the problem RLS solves.
  • You can't control the database role. On platforms that force superuser connections, RLS silently doesn't apply — a with_loader_criteria filter is then better than a false sense of safety.
  • Extremely hot paths where the policy predicate shows up in profiles and you've measured it: hand-tuned queries may be preferable for those specific paths.

For everything else multi-tenant on FastAPI + PostgreSQL, database-enforced isolation is the option that doesn't depend on nobody ever making a mistake. Start with the quick start.

Written and maintained by · source on GitHub