<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jan Dvorak</title>
    <description>The latest articles on DEV Community by Jan Dvorak (@dvoraj75).</description>
    <link>https://dev.to/dvoraj75</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3825583%2Fe85edd79-44e7-45a3-8cdb-32ccdd672575.jpeg</url>
      <title>DEV Community: Jan Dvorak</title>
      <link>https://dev.to/dvoraj75</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dvoraj75"/>
    <language>en</language>
    <item>
      <title>PostgreSQL RLS Is Fail-Closed, But Is It Fast? Making django-rls-tenants Index-Friendly</title>
      <dc:creator>Jan Dvorak</dc:creator>
      <pubDate>Tue, 17 Mar 2026 19:02:04 +0000</pubDate>
      <link>https://dev.to/dvoraj75/postgresql-rls-is-fail-closed-but-is-it-fast-making-django-rls-tenants-index-friendly-2j18</link>
      <guid>https://dev.to/dvoraj75/postgresql-rls-is-fail-closed-but-is-it-fast-making-django-rls-tenants-index-friendly-2j18</guid>
      <description>&lt;p&gt;A couple of days ago I published a post about &lt;a href="https://dev.to/dvoraj75/why-postgresql-row-level-security-is-the-right-approach-to-django-multitenancy-3e1m"&gt;why PostgreSQL Row-Level Security is the right approach to Django multitenancy&lt;/a&gt;. The short version: application-level filtering is opt-in, RLS is opt-out. One fails by leaking data, the other fails by returning nothing.&lt;/p&gt;

&lt;p&gt;I still stand by all of that. But after spending more time with RLS on actual data -- not the neat 50-row test tables I started with -- I ran into something the PostgreSQL documentation doesn't make obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RLS policies have a performance cost, and it's not where you'd expect it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The isolation itself is rock solid. The problem is the query planner. PostgreSQL has opinions about how RLS policy expressions get evaluated, and those opinions can prevent your indexes from doing their job. I only noticed because a query that &lt;em&gt;should&lt;/em&gt; have been fast wasn't, and &lt;code&gt;EXPLAIN&lt;/code&gt; told me a story I didn't expect.&lt;/p&gt;

&lt;p&gt;This post is about what I found, why it happens, and what changed in &lt;a href="https://github.com/dvoraj75/django-rls-tenants" rel="noopener noreferrer"&gt;django-rls-tenants 1.1.0&lt;/a&gt; to fix it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: RLS Policies Can't Use Your Indexes
&lt;/h2&gt;

&lt;p&gt;Here's the setup. You have an &lt;code&gt;orders&lt;/code&gt; table with a &lt;code&gt;tenant_id&lt;/code&gt; column and a composite index on &lt;code&gt;(tenant_id, created_at)&lt;/code&gt;. You've enabled RLS with a policy that checks &lt;code&gt;tenant_id = current_setting('rls.current_tenant')::integer&lt;/code&gt;. You run a query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RLS appends the policy filter automatically. You'd expect PostgreSQL to use your composite index to efficiently scan just the rows belonging to the current tenant. That's what the index is &lt;em&gt;for&lt;/em&gt;. Makes sense, right?&lt;/p&gt;

&lt;p&gt;It doesn't happen. PostgreSQL does a sequential scan on the entire table and applies the RLS filter row-by-row.&lt;/p&gt;

&lt;p&gt;I stared at this for longer than I'd like to admit before I understood why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Happens
&lt;/h3&gt;

&lt;p&gt;The reason is a security mechanism called &lt;strong&gt;security barriers&lt;/strong&gt;. When PostgreSQL evaluates an RLS policy, it treats the policy expression as a "security barrier qual." This is intentional -- it prevents user-defined functions in regular WHERE clauses from seeing rows that the RLS policy would have filtered out. Without this protection, a cleverly crafted function could leak information about rows the user shouldn't see.&lt;/p&gt;

&lt;p&gt;The consequence is that PostgreSQL will only push a security barrier expression into an index scan if every function in that expression is marked as &lt;strong&gt;leakproof&lt;/strong&gt;. A leakproof function is one that PostgreSQL guarantees will never reveal information about its arguments through error messages, side effects, or any other channel.&lt;/p&gt;

&lt;p&gt;And here's the thing: &lt;code&gt;current_setting()&lt;/code&gt; -- the function that RLS policies use to read session variables -- is &lt;strong&gt;not&lt;/strong&gt; leakproof. PostgreSQL can't guarantee it won't throw an error that reveals something about a row's data. So the planner refuses to push it into an index condition and falls back to evaluating it as a filter on every row after reading them.&lt;/p&gt;

&lt;p&gt;This is the correct behavior from a security perspective. PostgreSQL is being conservative, and for good reason. But it means that on a table with a million rows across 500 tenants, every single query does a sequential scan and filters down to 2,000 rows after the fact. Your carefully designed composite index just sits there.&lt;/p&gt;

&lt;p&gt;For small tables, none of this matters. For anything above a few tens of thousands of rows, it starts to show.&lt;/p&gt;

&lt;h3&gt;
  
  
  What This Looks Like in EXPLAIN
&lt;/h3&gt;

&lt;p&gt;If you run &lt;code&gt;EXPLAIN&lt;/code&gt; on a query against an RLS-protected table, you'll see something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on orders  (cost=0.00..2541.00 rows=500 width=64)
  Filter: (tenant_id = (current_setting('rls.current_tenant',...))::integer)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Filter&lt;/code&gt; line is the giveaway. The RLS expression is evaluated per-row, after a full table scan. Now compare that to what you get when you add an explicit &lt;code&gt;WHERE tenant_id = 42&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Index Scan using idx_orders_tenant_created on orders  (cost=0.42..85.30 rows=500 width=64)
  Index Cond: (tenant_id = 42)
  Filter: (tenant_id = (current_setting('rls.current_tenant',...))::integer)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the planner uses the index for the explicit &lt;code&gt;WHERE&lt;/code&gt; condition and applies the RLS policy as a secondary filter on the already-scoped rows. Both conditions evaluate to the same thing, but the planner trusts the explicit one and won't trust the RLS one.&lt;/p&gt;

&lt;p&gt;Same logic. Completely different execution path.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: A Redundant WHERE Clause That's Not Actually Redundant
&lt;/h2&gt;

&lt;p&gt;Once I understood the problem, the fix was almost obvious: if the ORM adds an explicit &lt;code&gt;WHERE tenant_id = X&lt;/code&gt; to every query, the planner can use it for index scans. The RLS policy still runs as a secondary filter -- the fail-closed guarantee stays intact -- but the heavy lifting of narrowing down rows is done by the index.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;v1.0&lt;/strong&gt;, &lt;code&gt;RLSManager.get_queryset()&lt;/code&gt; returned a plain &lt;code&gt;TenantQuerySet&lt;/code&gt; with no filters. Tenant scoping was entirely handled by PostgreSQL via the RLS policy. Correct, but slow.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;v1.1&lt;/strong&gt;, &lt;code&gt;RLSManager.get_queryset()&lt;/code&gt; checks whether a tenant context is active and, if so, adds &lt;code&gt;.filter(tenant_id=X)&lt;/code&gt; automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before (v1.0): RLS only
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_queryset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TenantQuerySet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# After (v1.1): ORM filter + RLS
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_queryset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;qs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TenantQuerySet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_current_tenant_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;qs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;qs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The natural question: how does &lt;code&gt;get_current_tenant_id()&lt;/code&gt; know which tenant is active?&lt;/p&gt;

&lt;h3&gt;
  
  
  ContextVar-Based Tenant State
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;v1.1&lt;/strong&gt; introduces a new module (&lt;code&gt;tenants/state.py&lt;/code&gt;) that uses Python's &lt;code&gt;contextvars.ContextVar&lt;/code&gt; to track the current tenant ID. When you use &lt;code&gt;tenant_context()&lt;/code&gt;, &lt;code&gt;admin_context()&lt;/code&gt;, or the &lt;code&gt;RLSTenantMiddleware&lt;/code&gt;, the library now sets both the PostgreSQL GUC variable (for RLS) &lt;strong&gt;and&lt;/strong&gt; the ContextVar (for ORM auto-scoping).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django_rls_tenants&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tenant_context&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;tenant_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# This now does TWO things:
&lt;/span&gt;    &lt;span class="c1"&gt;# 1. SET rls.current_tenant = '42'  (database-level, for RLS)
&lt;/span&gt;    &lt;span class="c1"&gt;# 2. ContextVar = 42                (Python-level, for ORM filter)
&lt;/span&gt;
    &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# Generated SQL: SELECT ... FROM orders WHERE tenant_id = 42
&lt;/span&gt;    &lt;span class="c1"&gt;# Plus RLS policy applied by PostgreSQL as secondary filter
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't change any of your code. If you're already using &lt;code&gt;tenant_context()&lt;/code&gt; or the middleware, every query gets the ORM filter automatically. The RLS policy still enforces isolation at the database level -- the ORM filter just helps the planner find the right rows faster.&lt;/p&gt;

&lt;p&gt;This is defense in depth that also happens to be a performance win. The ORM filter handles the "fast path" via index scans. The RLS policy handles the "safe path" -- catching raw SQL, &lt;code&gt;dbshell&lt;/code&gt;, migrations, and anything else that bypasses the ORM. You get both.&lt;/p&gt;




&lt;h2&gt;
  
  
  select_related() Across RLS-Protected Joins
&lt;/h2&gt;

&lt;p&gt;The same index problem applies to JOINs. If you do &lt;code&gt;OrderItem.objects.select_related('order')&lt;/code&gt; and both &lt;code&gt;OrderItem&lt;/code&gt; and &lt;code&gt;Order&lt;/code&gt; are RLS-protected, PostgreSQL joins the tables and then applies RLS filters to both sides row-by-row. The indexes on the joined table don't help there either.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;v1.1&lt;/strong&gt;, &lt;code&gt;TenantQuerySet.select_related()&lt;/code&gt; detects when a joined model is RLS-protected and adds a tenant filter on the joined table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;tenant_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;order&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# Generated SQL includes:
&lt;/span&gt;    &lt;span class="c1"&gt;#   WHERE order_item.tenant_id = 42
&lt;/span&gt;    &lt;span class="c1"&gt;#     AND (order.tenant_id = 42 OR order_item.order_id IS NULL)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The library walks the relation path, checks whether the target model has an &lt;code&gt;RLSConstraint&lt;/code&gt; in its Meta (results are cached for the process lifetime), and adds the appropriate filter. For nullable foreign keys, it preserves LEFT OUTER JOIN semantics -- rows where the FK is NULL are kept, not silently dropped by the inner join that &lt;code&gt;.filter()&lt;/code&gt; would normally force.&lt;/p&gt;

&lt;p&gt;If you're already using &lt;code&gt;select_related()&lt;/code&gt; in your views, the tenant filters are added automatically when a context is active. Nothing to change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Policy Rewrite: CASE WHEN Instead of OR
&lt;/h2&gt;

&lt;p&gt;While I was in the &lt;code&gt;EXPLAIN&lt;/code&gt; output anyway, I also took a closer look at the structure of the RLS policy itself. In &lt;strong&gt;v1.0&lt;/strong&gt;, the policy used an &lt;code&gt;OR&lt;/code&gt;-based structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- v1.0 policy&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nullif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.is_admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things bothered me. First, the &lt;code&gt;coalesce(..., NULL)&lt;/code&gt; wrapping the tenant match is redundant -- &lt;code&gt;nullif()&lt;/code&gt; already returns NULL when the setting is empty, and &lt;code&gt;NULL::integer&lt;/code&gt; is NULL. The extra &lt;code&gt;coalesce&lt;/code&gt; does nothing.&lt;/p&gt;

&lt;p&gt;Second, the &lt;code&gt;OR&lt;/code&gt; structure means PostgreSQL evaluates both branches. In &lt;strong&gt;v1.1&lt;/strong&gt;, the policy uses &lt;code&gt;CASE WHEN&lt;/code&gt; instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- v1.1 policy&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.is_admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;
         &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
         &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;nullif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;integer&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;CASE WHEN&lt;/code&gt; checks the admin flag first. If it's an admin session, it short-circuits to &lt;code&gt;true&lt;/code&gt; without evaluating the tenant match. For regular tenant sessions, it evaluates just the tenant match.&lt;/p&gt;

&lt;p&gt;I want to be honest here: the real performance win in this release comes from the ORM-level auto-scoping described above, not from this policy rewrite. The &lt;code&gt;CASE WHEN&lt;/code&gt; change is more about clarity and correctness -- the evaluation order is explicit, and the policy reads more naturally. But I wouldn't call it a performance optimization by itself.&lt;/p&gt;

&lt;p&gt;If you run &lt;code&gt;makemigrations&lt;/code&gt; after upgrading, the library generates a migration that updates existing policies to the new structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hardening: ASGI, Exceptions, and Superusers
&lt;/h2&gt;

&lt;p&gt;Beyond performance, &lt;strong&gt;v1.1&lt;/strong&gt; fixes several correctness issues I should have caught earlier. These are the kind of things that work fine in development and then bite you in production under specific conditions.&lt;/p&gt;

&lt;h3&gt;
  
  
  threading.local to ContextVar
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;v1.0&lt;/strong&gt; middleware used &lt;code&gt;threading.local()&lt;/code&gt; to track whether GUC variables had been set during a request. This works fine in WSGI, where each request gets its own thread. In ASGI, though, multiple coroutines can share a single thread. &lt;code&gt;threading.local()&lt;/code&gt; doesn't distinguish between them, so one coroutine's state can leak into another's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.1&lt;/strong&gt; replaces all &lt;code&gt;threading.local()&lt;/code&gt; usage with &lt;code&gt;contextvars.ContextVar&lt;/code&gt;, which provides proper per-coroutine isolation. If you're running Django under an ASGI server (Uvicorn, Daphne, Hypercorn), this is the fix you didn't know you needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Middleware Exception Handling
&lt;/h3&gt;

&lt;p&gt;In &lt;strong&gt;v1.0&lt;/strong&gt;, if a view raised an unhandled exception, Django's middleware chain might skip &lt;code&gt;process_response&lt;/code&gt;. That meant the GUC variables (and now the ContextVar) could remain set for the next request on the same connection. Not great.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.1&lt;/strong&gt; adds a &lt;code&gt;process_exception()&lt;/code&gt; method to the middleware that runs the same cleanup. Between &lt;code&gt;process_response&lt;/code&gt;, &lt;code&gt;process_exception&lt;/code&gt;, and the &lt;code&gt;request_finished&lt;/code&gt; signal safety net, there are now three independent layers of cleanup. Belt, suspenders, and a backup pair of suspenders.&lt;/p&gt;

&lt;h3&gt;
  
  
  Superuser Detection at Startup
&lt;/h3&gt;

&lt;p&gt;This one is embarrassing that it wasn't there from the start. PostgreSQL superusers bypass &lt;em&gt;all&lt;/em&gt; RLS policies. &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt; doesn't override it. If your Django application connects to PostgreSQL as a superuser, every RLS policy is silently ignored and every query returns all tenants' data.&lt;/p&gt;

&lt;p&gt;This is the single most dangerous misconfiguration possible with RLS, and in &lt;strong&gt;v1.0&lt;/strong&gt; there was no warning about it. &lt;strong&gt;v1.1&lt;/strong&gt; adds a Django system check (&lt;code&gt;W005&lt;/code&gt;) that queries &lt;code&gt;pg_user&lt;/code&gt; at startup and warns you immediately if the current connection is a superuser.&lt;/p&gt;

&lt;p&gt;I mentioned this risk in the first post. The example project ships with a &lt;code&gt;docker/init-db.sql&lt;/code&gt; that creates a non-superuser role. But mentioning it in docs is not the same as catching it automatically. Now the library catches it for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fail-Fast on Bad User Configuration
&lt;/h3&gt;

&lt;p&gt;In &lt;strong&gt;v1.0&lt;/strong&gt;, if a non-admin user had &lt;code&gt;rls_tenant_id = None&lt;/code&gt; (typically because the user model wasn't fully configured), the library would stringify it as &lt;code&gt;"None"&lt;/code&gt; and pass that to PostgreSQL. The GUC variable would be set to the literal string &lt;code&gt;"None"&lt;/code&gt;, which would fail the integer cast in the RLS policy and return zero rows. Technically correct behavior -- fail-closed -- but completely unhelpful when you're trying to figure out why your users see empty pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.1&lt;/strong&gt; raises a &lt;code&gt;ValueError&lt;/code&gt; immediately with a clear message: &lt;em&gt;"Non-admin user has rls_tenant_id=None. Assign the user to a tenant or set is_tenant_admin=True."&lt;/em&gt; The same validation applies to &lt;code&gt;@with_rls_context&lt;/code&gt;, which now includes the decorated function name in the error message so you know exactly where the problem is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug Fixes
&lt;/h2&gt;

&lt;p&gt;Two quick ones worth mentioning:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom TENANT_FK_FIELD was partially broken.&lt;/strong&gt; The &lt;code&gt;class_prepared&lt;/code&gt; signal handler that auto-adds the tenant FK to models was checking for a field named &lt;code&gt;"tenant"&lt;/code&gt; regardless of your &lt;code&gt;TENANT_FK_FIELD&lt;/code&gt; setting. If you configured &lt;code&gt;TENANT_FK_FIELD = "organization"&lt;/code&gt;, the handler wouldn't find &lt;code&gt;"tenant"&lt;/code&gt; in the model's fields and would try to add a second FK. Fixed -- it now reads the configured field name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CONN_MAX_AGE=None slipped through the system check.&lt;/strong&gt; The W004 check warns about persistent connections with session-scoped GUCs (risk of GUC state leaking between requests). But &lt;code&gt;CONN_MAX_AGE=None&lt;/code&gt; -- Django's sentinel for "keep connections alive forever" -- wasn't being caught because the check only tested for positive integers. &lt;code&gt;None&lt;/code&gt; is arguably the most dangerous value here. Fixed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;v1.0&lt;/strong&gt; was about correctness: making the database enforce tenant isolation so your application code doesn't have to be perfect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.1&lt;/strong&gt; is about making that correctness fast.&lt;/p&gt;

&lt;p&gt;The core change is straightforward: add an ORM-level &lt;code&gt;WHERE tenant_id = X&lt;/code&gt; alongside the RLS policy, so PostgreSQL can use your indexes for the fast path while RLS handles the safe path. Everything else -- the &lt;code&gt;CASE WHEN&lt;/code&gt; policy rewrite, &lt;code&gt;select_related()&lt;/code&gt; propagation, ContextVar migration, exception handling -- either supports that central idea or hardens the library for production use.&lt;/p&gt;

&lt;p&gt;An empty page is still better than a data leak. But a fast, correct page is better than both.&lt;/p&gt;

&lt;p&gt;The full changelog is in the &lt;a href="https://github.com/dvoraj75/django-rls-tenants/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;. If you run into anything, &lt;a href="https://github.com/dvoraj75/django-rls-tenants/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; or &lt;a href="https://github.com/dvoraj75/django-rls-tenants/discussions" rel="noopener noreferrer"&gt;start a discussion&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/dvoraj75/django-rls-tenants" rel="noopener noreferrer"&gt;django-rls-tenants&lt;/a&gt; is open-source and MIT licensed. If you find it useful, a GitHub star helps others discover it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>postgres</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why PostgreSQL Row-Level Security Is the Right Approach to Django Multitenancy</title>
      <dc:creator>Jan Dvorak</dc:creator>
      <pubDate>Sun, 15 Mar 2026 19:49:33 +0000</pubDate>
      <link>https://dev.to/dvoraj75/why-postgresql-row-level-security-is-the-right-approach-to-django-multitenancy-3e1m</link>
      <guid>https://dev.to/dvoraj75/why-postgresql-row-level-security-is-the-right-approach-to-django-multitenancy-3e1m</guid>
      <description>&lt;p&gt;Every SaaS application is a promise: "Your data is yours, and only yours."&lt;/p&gt;

&lt;p&gt;Every Django developer who's built a multi-tenant app knows how fragile that promise actually is.&lt;/p&gt;

&lt;p&gt;I'll be honest about how I got here: I was building a Django application and I was, let's say, a bit overambitious. Instead of shipping an MVP first and worrying about multitenancy later, I convinced myself that thousands of customers were right around the corner and I needed to solve tenant isolation &lt;em&gt;now&lt;/em&gt;. Classic over-engineering move. I know.&lt;/p&gt;

&lt;p&gt;But that premature optimization turned into a genuine education. I started evaluating the options, and each one gave me a different kind of anxiety:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate databases per tenant?&lt;/strong&gt; Terrible. Connection management alone would be a nightmare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema-per-tenant?&lt;/strong&gt; Better, but I kept reading about migration times growing linearly and PostgreSQL catalog bloat at scale. That didn't feel like something I wanted to sign up for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application-level filtering with custom managers?&lt;/strong&gt; The cleanest option on the surface. But one question kept nagging me: &lt;strong&gt;what happens when I forget?&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And I &lt;em&gt;would&lt;/em&gt; forget. I know myself. A management command here, a Celery task there, a quick raw SQL query to debug something in production -- any of these could silently bypass my carefully constructed ORM filters and return data from every tenant in the system.&lt;/p&gt;

&lt;p&gt;That question -- "what happens when I forget?" -- led me down the path of PostgreSQL Row-Level Security, and eventually to building &lt;a href="https://github.com/dvoraj75/django-rls-tenants" rel="noopener noreferrer"&gt;django-rls-tenants&lt;/a&gt;. A library that moves tenant isolation from "something your application code must remember to do" to "something your database enforces whether you remember or not."&lt;/p&gt;

&lt;p&gt;This post is about why I think that shift matters, how PostgreSQL makes it possible, and when it's the right (and wrong) choice for your project.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Multitenancy Problem Nobody Talks About Honestly
&lt;/h2&gt;

&lt;p&gt;If you're building a SaaS product with Django, you're almost certainly building a multi-tenant application. Multiple customers share the same application instance, the same database, the same tables. The only thing separating Acme Corp's invoices from Globex's invoices is a &lt;code&gt;tenant_id&lt;/code&gt; column and your team's diligence in never forgetting to filter by it.&lt;/p&gt;

&lt;p&gt;Let's be blunt about how that usually goes.&lt;/p&gt;

&lt;p&gt;Here's a typical Django view in a multi-tenant app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@login_required&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invoice_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoices/list.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. Now here's the same model being used in a Celery task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@shared_task&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_overdue_reminders&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;overdue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;due_date__lt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unpaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;overdue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;send_reminder_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the problem? There's no tenant filter. This task processes invoices across every tenant. If it's just sending emails, maybe that's intentional. But what if the task also generates a report and attaches it? Now you've got a tenant receiving a PDF that contains another tenant's data.&lt;/p&gt;

&lt;p&gt;This isn't a contrived example. This is the &lt;em&gt;default&lt;/em&gt; outcome whenever you write code that doesn't explicitly filter by tenant. The safe behavior has to be opted &lt;em&gt;into&lt;/em&gt;, every single time, in every single query.&lt;/p&gt;

&lt;h3&gt;
  
  
  This Isn't a Hypothetical Risk
&lt;/h3&gt;

&lt;p&gt;Insecure Direct Object Reference (IDOR) -- which is exactly what a missing tenant filter amounts to -- is one of the most commonly reported vulnerabilities on bug bounty platforms. OWASP moved Broken Access Control to the &lt;strong&gt;#1 position&lt;/strong&gt; in their &lt;a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/" rel="noopener noreferrer"&gt;2021 Top 10&lt;/a&gt;, noting that 94% of applications tested had some form of broken access control.&lt;/p&gt;

&lt;p&gt;In the multi-tenant SaaS world specifically, tenant data leaks have hit companies of all sizes. The pattern is almost always the same: an API endpoint, background job, or admin tool that queries the database without proper tenant scoping. Not because the developers were careless, but because the architecture made it possible to forget.&lt;/p&gt;

&lt;p&gt;The fundamental issue is that &lt;strong&gt;application-level filtering is an opt-in safety mechanism&lt;/strong&gt;. Every ORM query, every raw SQL statement, every management command, every data migration -- all of them start with access to everything and must explicitly narrow down. The failure mode is "leak all data." The success mode requires perfect discipline across every developer, every commit, every line of code, forever.&lt;/p&gt;

&lt;p&gt;That's not a security model. That's a hope.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Django Developers Solve This Today
&lt;/h2&gt;

&lt;p&gt;There are two mainstream approaches, and both have real tradeoffs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approach 1: Schema-Per-Tenant (django-tenants)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/django-tenants/django-tenants" rel="noopener noreferrer"&gt;django-tenants&lt;/a&gt; gives each tenant their own PostgreSQL schema. Tenant A's data lives in &lt;code&gt;schema_a.invoices&lt;/code&gt;, Tenant B's in &lt;code&gt;schema_b.invoices&lt;/code&gt;. The library routes each request to the correct schema by setting &lt;code&gt;search_path&lt;/code&gt; on the database connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's good about it:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Strong isolation -- different tenants literally have different tables&lt;/li&gt;
&lt;li&gt;Mature library with a large user base&lt;/li&gt;
&lt;li&gt;Supports per-tenant schema customization (different tenants can have different database structures)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where it hurts:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Migrations scale linearly.&lt;/strong&gt; Got 500 tenants? Your &lt;code&gt;migrate&lt;/code&gt; command runs 500 times. It's not uncommon to see reports of deploys taking 45+ minutes just running migrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL catalog bloat.&lt;/strong&gt; Each schema means a separate set of entries in &lt;code&gt;pg_class&lt;/code&gt;, &lt;code&gt;pg_attribute&lt;/code&gt;, and other system catalogs. At thousands of tenants, &lt;code&gt;\dt&lt;/code&gt; in &lt;code&gt;psql&lt;/code&gt; becomes unusably slow, and the planner can struggle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection pooling gets complicated.&lt;/strong&gt; Each connection is pinned to a schema via &lt;code&gt;search_path&lt;/code&gt;, which fights with PgBouncer's transaction-mode pooling. You either need session-mode pooling (less efficient) or careful workarounds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational complexity.&lt;/strong&gt; Backup, restore, monitoring -- everything has to account for N schemas instead of one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For 10-50 tenants, schema-per-tenant works well. At 500+, the operational burden is significant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approach 2: Application-Level Filtering (django-multitenant, Custom Managers)
&lt;/h3&gt;

&lt;p&gt;The other approach keeps everything in a single schema and filters at the application level. &lt;a href="https://github.com/citusdata/django-multitenant" rel="noopener noreferrer"&gt;django-multitenant&lt;/a&gt; (designed for Citus) rewrites ORM queries to inject tenant filters. Many teams build their own version with custom managers and middleware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's good about it:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single schema, standard migrations, simple operations&lt;/li&gt;
&lt;li&gt;Works well with ORMs -- queries look clean&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The fundamental problem:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Application-level filtering only protects the paths it knows about. Here's an incomplete list of things that bypass it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raw SQL:&lt;/strong&gt; &lt;code&gt;cursor.execute("SELECT * FROM invoices")&lt;/code&gt; -- your custom manager isn't involved&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Management commands:&lt;/strong&gt; No request, no middleware, no automatic scoping&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data migrations:&lt;/strong&gt; &lt;code&gt;apps.get_model("myapp", "Invoice").objects.all()&lt;/code&gt; -- uses the historical model, not your custom manager&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dbshell:&lt;/strong&gt; &lt;code&gt;python manage.py dbshell&lt;/code&gt; -- you're talking directly to PostgreSQL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party packages:&lt;/strong&gt; Any library that queries your models directly (analytics, export tools, admin panels) might not use your filtered manager&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signals and hooks:&lt;/strong&gt; &lt;code&gt;post_save&lt;/code&gt; handlers that query related models might not have tenant context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The safety of your entire system depends on &lt;em&gt;every&lt;/em&gt; code path going through the filtered manager. That's a big "every."&lt;/p&gt;

&lt;h3&gt;
  
  
  The Comparison at a Glance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Schema-Per-Tenant&lt;/th&gt;
&lt;th&gt;App-Level Filtering&lt;/th&gt;
&lt;th&gt;Database-Enforced (RLS)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw SQL protected?&lt;/td&gt;
&lt;td&gt;Yes (via search_path)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dbshell protected?&lt;/td&gt;
&lt;td&gt;Partially (must set search_path)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Management commands?&lt;/td&gt;
&lt;td&gt;Must route to correct schema&lt;/td&gt;
&lt;td&gt;Must add filtering manually&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration complexity&lt;/td&gt;
&lt;td&gt;Runs N times (once per tenant)&lt;/td&gt;
&lt;td&gt;Single run&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Single run&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000+ tenant scaling&lt;/td&gt;
&lt;td&gt;Catalog bloat, slow migrations&lt;/td&gt;
&lt;td&gt;Fine&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Fine&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fail mode on missing context&lt;/td&gt;
&lt;td&gt;Wrong schema (error or wrong data)&lt;/td&gt;
&lt;td&gt;All data returned&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Zero rows returned&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one I care about most. When something goes wrong with application-level filtering, the failure mode is &lt;em&gt;returning data from all tenants&lt;/em&gt;. When something goes wrong with RLS, the failure mode is &lt;em&gt;returning nothing&lt;/em&gt;. An empty page is embarrassing. A data leak is a breach.&lt;/p&gt;




&lt;h2&gt;
  
  
  What PostgreSQL Row-Level Security Actually Does
&lt;/h2&gt;

&lt;p&gt;Row-Level Security (RLS) has been in PostgreSQL since version 9.5 (released in 2016). It's not new, not experimental, and not an extension -- it's a core feature of the database. Yet it seems like most Django developers haven't heard of it, or think it's only for edge cases.&lt;/p&gt;

&lt;p&gt;Here's the basic idea: you attach a &lt;em&gt;policy&lt;/em&gt; to a table that defines which rows a given session can see. The database enforces this policy on every query -- &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; -- regardless of whether the query comes from an ORM, raw SQL, a migration, or someone typing directly into &lt;code&gt;psql&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Simple Example
&lt;/h3&gt;

&lt;p&gt;Let's say you have an &lt;code&gt;invoices&lt;/code&gt; table with a &lt;code&gt;tenant_id&lt;/code&gt; column. You want each database session to only see rows belonging to a specific tenant. Here's how that looks in plain SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Step 1: Tell the database session which tenant we are&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'42'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Step 2: Query the table normally&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Returns ONLY rows where tenant_id = 42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No WHERE clause. No filter. The database itself appends the filter to every query against this table.&lt;/p&gt;

&lt;p&gt;How does the database know to do this? Because you've defined a policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Enable RLS on the table&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Create the policy&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's break this down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt;&lt;/strong&gt; turns on the RLS mechanism for this table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;&lt;/strong&gt; ensures the policy applies even to the table owner. Without this, the user who owns the table would bypass all policies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CREATE POLICY ... USING (...)&lt;/code&gt;&lt;/strong&gt; defines the visibility rule. The &lt;code&gt;USING&lt;/code&gt; clause is evaluated for every row: if it returns &lt;code&gt;true&lt;/code&gt;, the row is visible; if &lt;code&gt;false&lt;/code&gt;, the row doesn't exist as far as the query is concerned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;current_setting('rls.current_tenant', true)&lt;/code&gt;&lt;/strong&gt; reads a session variable (called a GUC in PostgreSQL terminology). The &lt;code&gt;true&lt;/code&gt; parameter means "return empty string instead of error if the variable isn't set."&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Fail-Closed Guarantee
&lt;/h3&gt;

&lt;p&gt;Here's the thing that makes RLS fundamentally different from application-level filtering. What happens when nobody has set the &lt;code&gt;rls.current_tenant&lt;/code&gt; variable?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- No set_config() call -- tenant variable is empty&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Returns: 0 rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy evaluates &lt;code&gt;tenant_id = NULL&lt;/code&gt; (because the empty string is cast to NULL), which is always &lt;code&gt;false&lt;/code&gt; in SQL. No rows match. No data is returned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the key insight: RLS is fail-closed by default.&lt;/strong&gt; Forgetting to set the context doesn't give you access to everything -- it gives you access to nothing. The accidental management command, the raw SQL query, the forgotten Celery task -- they all get zero rows instead of every tenant's data.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Full Policy (for the Curious)
&lt;/h3&gt;

&lt;p&gt;In practice, you want a slightly more sophisticated policy that also supports admin access (for cross-tenant operations like analytics or data migrations). Here's what that looks like:&lt;/p&gt;

&lt;p&gt;Click to expand the full policy SQL&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;invoices_tenant_isolation_policy&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
    &lt;span class="c1"&gt;-- USING clause: controls which rows are visible (SELECT, UPDATE, DELETE)&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="c1"&gt;-- Match the current tenant&lt;/span&gt;
        &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;NULL&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;-- OR allow admin access (full bypass)&lt;/span&gt;
        &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.is_admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;-- WITH CHECK clause: controls which rows can be written (INSERT, UPDATE)&lt;/span&gt;
    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;NULL&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rls.is_admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;USING&lt;/code&gt; clause filters reads, the &lt;code&gt;WITH CHECK&lt;/code&gt; clause validates writes. The &lt;code&gt;COALESCE(NULLIF(..., ''), NULL)&lt;/code&gt; chain handles the "variable not set" case gracefully, collapsing it to &lt;code&gt;NULL&lt;/code&gt; which makes the equality check fail. The admin flag provides an escape hatch for legitimate cross-tenant operations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Introducing django-rls-tenants
&lt;/h2&gt;

&lt;p&gt;All of the above is standard PostgreSQL. You could implement it yourself with raw SQL migrations and a middleware that calls &lt;code&gt;set_config()&lt;/code&gt;. But there's a lot of subtle detail to get right: policy generation, GUC management, connection lifecycle, testing, the interaction with Django's migration framework, connection pooling safety...&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/dvoraj75/django-rls-tenants" rel="noopener noreferrer"&gt;django-rls-tenants&lt;/a&gt; handles all of that. Here's how it works in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Define Your Model
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django_rls_tenants&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RLSProtectedModel&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RLSProtectedModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DecimalField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_digits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decimal_places&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;due_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unpaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RLSProtectedModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;ordering&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-due_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No &lt;code&gt;tenant&lt;/code&gt; ForeignKey declared -- the library adds it automatically via Django's &lt;code&gt;class_prepared&lt;/code&gt; signal. No custom manager boilerplate -- &lt;code&gt;RLSProtectedModel&lt;/code&gt; sets up &lt;code&gt;RLSManager&lt;/code&gt; as the default manager. And crucially, the &lt;code&gt;RLSConstraint&lt;/code&gt; in the abstract model's &lt;code&gt;Meta.constraints&lt;/code&gt; generates the full RLS policy during &lt;code&gt;migrate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;python manage.py migrate&lt;/code&gt;, the library creates the table, enables RLS, forces RLS, and creates the tenant isolation policy. All in a single, standard migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Configure the Settings
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;INSTALLED_APPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;# ...
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django_rls_tenants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# ... your apps
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;RLS_TENANTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TENANT_MODEL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.Tenant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# your tenant model
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TENANT_FK_FIELD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# FK field name (auto-added)
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GUC_PREFIX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                  &lt;span class="c1"&gt;# PostgreSQL variable prefix
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TENANT_PK_TYPE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;# or "bigint" or "uuid"
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USE_LOCAL_SET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;# True for connection pooling
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;MIDDLEWARE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;# ...
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django.contrib.auth.middleware.AuthenticationMiddleware&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django_rls_tenants.RLSTenantMiddleware&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# after auth
&lt;/span&gt;    &lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Implement the TenantUser Protocol
&lt;/h3&gt;

&lt;p&gt;The library needs to know two things about a user: which tenant they belong to, and whether they're an admin who should see all tenants. You implement this via a simple protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AbstractUser&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AbstractUser&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.Tenant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# null for admin users
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;is_tenant_admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BooleanField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rls_tenant_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties: &lt;code&gt;is_tenant_admin&lt;/code&gt; and &lt;code&gt;rls_tenant_id&lt;/code&gt;. That's the entire contract. You don't need to inherit from any special base class -- it's structural typing via Python's &lt;code&gt;Protocol&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Write Views Without Thinking About Tenant Filtering
&lt;/h3&gt;

&lt;p&gt;And here's the payoff. This is what your views look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@login_required&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invoice_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# no .filter(tenant=...)
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoices/list.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No tenant filter. &lt;code&gt;Invoice.objects.all()&lt;/code&gt; returns only the current tenant's invoices, because the middleware has already told PostgreSQL which tenant this session belongs to. The database does the filtering.&lt;/p&gt;

&lt;p&gt;And here's the management command from earlier -- the one that was a data leak risk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django_rls_tenants&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tenant_context&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseCommand&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;tenant_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;overdue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;due_date__lt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unpaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tenant &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;overdue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; overdue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;tenant_context&lt;/code&gt; context manager sets the PostgreSQL session variable for the duration of the block. If someone forgets to wrap their queries in a &lt;code&gt;tenant_context&lt;/code&gt;? Zero rows. Not all rows -- zero rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  How It Flows
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP Request
    |
    v
AuthenticationMiddleware  --&amp;gt;  sets request.user
    |
    v
RLSTenantMiddleware       --&amp;gt;  reads user.rls_tenant_id
    |                          calls SET rls.current_tenant = '42'
    |                          calls SET rls.is_admin = 'false'
    v
Your View                 --&amp;gt;  Invoice.objects.all()
    |
    v
PostgreSQL                --&amp;gt;  applies RLS policy automatically
    |                          WHERE tenant_id = 42
    v
Response (only tenant 42's invoices)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middleware sets two PostgreSQL session variables: &lt;code&gt;rls.current_tenant&lt;/code&gt; (the tenant ID) and &lt;code&gt;rls.is_admin&lt;/code&gt; (whether to bypass tenant filtering). After the response is sent, both variables are cleared. There's even a safety net: a &lt;code&gt;request_finished&lt;/code&gt; signal handler clears the variables if the middleware's &lt;code&gt;process_response&lt;/code&gt; doesn't run for any reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defense in Depth
&lt;/h3&gt;

&lt;p&gt;One design decision worth highlighting: when you use the &lt;code&gt;for_user()&lt;/code&gt; queryset method, the library applies &lt;strong&gt;both&lt;/strong&gt; a Django ORM &lt;code&gt;.filter(tenant_id=...)&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; the RLS GUC variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# This does TWO things:
# 1. ORM: .filter(tenant_id=user.rls_tenant_id)  -- Django-level
# 2. GUC: SET rls.current_tenant = '42'           -- PostgreSQL-level
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either layer alone would be sufficient for isolation. Together, they provide defense in depth: even if one mechanism has a bug, the other catches it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Context Managers for Everything Else
&lt;/h3&gt;

&lt;p&gt;Not everything runs in an HTTP request. For Celery tasks, management commands, scripts, or tests, the library provides context managers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django_rls_tenants&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tenant_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;admin_context&lt;/span&gt;

&lt;span class="c1"&gt;# Scope to a specific tenant
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;tenant_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# only tenant 42's orders
&lt;/span&gt;
&lt;span class="c1"&gt;# Admin access (e.g., for cross-tenant analytics)
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;admin_context&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;all_orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# all tenants
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These nest correctly (saving and restoring previous GUC state), work in async code, and clean up properly even if exceptions are raised.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing That RLS Actually Works
&lt;/h3&gt;

&lt;p&gt;The library ships with test utilities to verify your RLS setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django_rls_tenants.tenants.testing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;rls_bypass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rls_as_tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;assert_rls_enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;assert_rls_policy_exists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;assert_rls_blocks_without_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_invoices_are_isolated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Verify the policy exists
&lt;/span&gt;    &lt;span class="nf"&gt;assert_rls_enabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp_invoice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assert_rls_policy_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp_invoice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Verify fail-closed behavior
&lt;/span&gt;    &lt;span class="nf"&gt;assert_rls_blocks_without_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Verify tenant isolation
&lt;/span&gt;    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;rls_as_tenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;  &lt;span class="c1"&gt;# only tenant_a's invoices
&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;rls_as_tenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;  &lt;span class="c1"&gt;# only tenant_b's invoices
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a management command for CI pipelines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;python manage.py check_rls
Checking RLS status &lt;span class="k"&gt;for &lt;/span&gt;3 models...
  myapp_invoice ......... OK
  myapp_order ........... OK
  myapp_document ........ OK
All 3 models have RLS properly configured.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  When NOT to Use This Approach
&lt;/h2&gt;

&lt;p&gt;I've spent this entire post arguing for RLS, so let me be equally honest about when it's not the right fit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need per-tenant schema customization.&lt;/strong&gt; If different tenants have different database structures (different columns, different tables), RLS on a shared schema won't work. Schema-per-tenant is the right choice here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're not on PostgreSQL.&lt;/strong&gt; RLS is a PostgreSQL feature. If you're on MySQL, SQLite, or another database, this entire approach doesn't apply. (Though honestly, if you're building a serious multi-tenant SaaS app, PostgreSQL is probably the right choice anyway.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You have very few tenants (&amp;lt; 5) and they won't grow.&lt;/strong&gt; If you're building an internal tool with 3 known customers, schema-per-tenant with &lt;code&gt;django-tenants&lt;/code&gt; is simpler and the scaling downsides won't bite you. Use the simplest solution that works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need cross-database compatibility.&lt;/strong&gt; If your application must run on multiple database backends, you can't rely on PostgreSQL-specific features. The library requires PostgreSQL 15+.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your database user is a superuser.&lt;/strong&gt; PostgreSQL superusers bypass all RLS policies. If your application connects to PostgreSQL as a superuser (common in development but a bad practice in production), RLS won't protect anything. The library's example project includes a &lt;code&gt;docker/init-db.sql&lt;/code&gt; that creates a non-superuser role specifically for this reason.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;If you want to try it out, the fastest path is the example project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/dvoraj75/django-rls-tenants.git
&lt;span class="nb"&gt;cd &lt;/span&gt;django-rls-tenants/example
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This starts a PostgreSQL database and a Django app with 3 tenants, 4 users, and sample data. Open &lt;code&gt;http://localhost:8000&lt;/code&gt;, log in as different users, and watch how each user only sees their tenant's data -- even though the views use &lt;code&gt;Note.objects.all()&lt;/code&gt; with no tenant filter.&lt;/p&gt;

&lt;p&gt;For adding it to your own project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;django-rls-tenants
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://dvoraj75.github.io/django-rls-tenants/getting-started/quickstart/" rel="noopener noreferrer"&gt;quickstart guide&lt;/a&gt; walks through the full setup in about 10 minutes. The &lt;a href="https://dvoraj75.github.io/django-rls-tenants/" rel="noopener noreferrer"&gt;full documentation&lt;/a&gt; covers configuration, context managers, bypass mode, testing, connection pooling, and migration guides if you're coming from &lt;code&gt;django-tenants&lt;/code&gt; or &lt;code&gt;django-multitenant&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The library supports Python 3.11-3.14, Django 4.2-6.0, and PostgreSQL 15+. It's &lt;a href="https://github.com/dvoraj75/django-rls-tenants/blob/main/LICENSE" rel="noopener noreferrer"&gt;MIT licensed&lt;/a&gt; and has a CI matrix that tests across all supported Python and Django versions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The core argument of this post is simple: &lt;strong&gt;tenant isolation is too important to enforce only in application code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Application-level filtering is like a door with a lock that only works if everyone remembers to close it. PostgreSQL RLS is a door that locks itself, every time, regardless of who walks through.&lt;/p&gt;

&lt;p&gt;If you're building a multi-tenant Django application on PostgreSQL, I think database-enforced isolation should be your default. Not because application-level filtering can't work -- it can and does, in thousands of production applications. But because the failure mode of "return nothing" is so fundamentally safer than "return everything" that it's worth the (minimal) additional setup.&lt;/p&gt;

&lt;p&gt;Give &lt;a href="https://github.com/dvoraj75/django-rls-tenants" rel="noopener noreferrer"&gt;django-rls-tenants&lt;/a&gt; a look. If you have questions or feedback, &lt;a href="https://github.com/dvoraj75/django-rls-tenants/discussions" rel="noopener noreferrer"&gt;open a discussion&lt;/a&gt; on GitHub. I'd genuinely love to hear what you think.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/dvoraj75/django-rls-tenants" rel="noopener noreferrer"&gt;django-rls-tenants&lt;/a&gt; is open-source and MIT licensed. If you find it useful, a GitHub star helps others discover it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>postgres</category>
      <category>security</category>
    </item>
  </channel>
</rss>
