๐Ÿš€ New: chi (ฯ‡) โ€” an open-source autoresearch harness for fleets of LLM coding agents. Read the announcement.

Building django-rls: PostgreSQL Row-Level Security as Django Model Metadata

Why ORM-level tenant filtering leaks, and how django-rls compiles Django Q objects into PostgreSQL row-level security policies enforced by the database.

Contents

django-rls has a one-line pitch: you declare row-level security policies as Django model metadata, and PostgreSQL โ€” not the ORM, not your discipline โ€” enforces them on every query, from every door into the table.

Every multi-tenant Django codebase I have worked on carries the same load-bearing convention: remember the filter. Every queryset that touches tenant data must end in .filter(tenant=request.tenant), every reviewer must catch the one that doesn’t, and every new hire must absorb the rule before they ship their first management command. The convention holds right up until it doesn’t โ€” and when it doesn’t, the failure mode isn’t an exception or a 500. It’s a page that renders perfectly, containing another customer’s data.

django-rls is my answer to that. It started as a scratch-your-own-itch weekend library and is now a 1.0 release on PyPI, the subject of my hands-on workshop at DjangoCon Europe 2026 in Athens, and the Django member of a small family of packages (more on the Flask and FastAPI siblings at the end).

The problem: isolation by convention

Think about how many ways a query reaches a multi-tenant table in a mature Django project. Views, yes โ€” those are the ones everyone remembers to filter. But also: the admin. Management commands. Celery tasks. Data migrations. That one analytics script someone runs from a shell. A raw() query in a report. A colleague’s objects.get(pk=...) in a webhook handler, written at 6pm on a Friday.

Application-level filtering has to win in all of those places, every time, forever. Database-enforced row-level security has to be configured correctly once. That asymmetry is the entire argument. PostgreSQL has shipped RLS since 9.5: you attach a policy to a table, and Postgres appends the policy’s predicate to every statement that touches it. A query that “forgets” the tenant filter doesn’t leak โ€” it just returns zero rows for other tenants, because the database itself refuses to show them.

The conventional Django move here is a custom manager โ€” override get_queryset() to inject the tenant filter so application code can’t forget it. I’ve written that manager. I did the opposite in django-rls โ€” pushed enforcement down into Postgres โ€” because the manager only guards the doors the ORM knows about. It does nothing for raw SQL, nothing for a second service reading the same database, nothing for psql in a debugging session, and it silently stops guarding anything the moment someone calls Model._base_manager or writes objects = models.Manager() on a subclass. The database is the only layer that sees every query. That’s where the isolation guarantee belongs.

Policies as model metadata

The design constraint I cared about most: the policy must live next to the model it protects, in Python, versioned with the code โ€” not in a migration someone hand-wrote two years ago, and not in a wiki page describing what the DBA ran in production. So models inherit from RLSModel and declare policies in Meta.rls_policies:

from django.db import models
from django.db.models import Q
from django_rls.models import RLSModel
from django_rls.policies import ModelPolicy, RLS

class Project(RLSModel):
    name = models.CharField(max_length=100)
    owner = models.ForeignKey(User, on_delete=models.CASCADE)
    is_public = models.BooleanField(default=False)

    class Meta:
        rls_policies = [
            # A row is visible if you own it OR it is public
            ModelPolicy(
                'access_policy',
                filters=Q(owner=RLS.user_id()) | Q(is_public=True),
            ),
        ]

That Q object is not decoration โ€” it compiles, through Django’s own SQL compiler, into a real CREATE POLICY statement that Postgres attaches to the table. Read it again with that in mind: the access rule for Project is written in the same vocabulary as every other Django filter you’ve ever written, sits eight lines from the fields it governs, and is enforced by the database engine.

There was a conventional option here too, and I rejected it deliberately. The obvious way to expose RLS from a Python package is raw SQL strings โ€” CustomPolicy("owner_id = current_setting('rls.user_id')::integer") โ€” and django-rls does ship that as an escape hatch, with a validation blocklist that rejects DDL and DML tokens. But I made Q objects the primary interface, because raw SQL strings in model Meta reintroduce exactly the class of stringly-typed, unreviewable, injection-adjacent code this library exists to eliminate. If the ORM can express the predicate, the ORM should compile the predicate.

For the two most common shapes โ€” tenant isolation and per-user ownership โ€” you don’t even need a Q. TenantPolicy and UserPolicy come ready-made:

from django_rls.policies import TenantPolicy

class Document(RLSModel):
    title = models.CharField(max_length=200)
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)

    class Meta:
        rls_policies = [
            TenantPolicy('tenant_isolation', tenant_field='tenant'),
        ]

which becomes, roughly, this DDL on the other side:

CREATE POLICY tenant_isolation ON myapp_document
    FOR ALL TO public
    USING (
        tenant_id = (SELECT NULLIF(current_setting('rls.tenant_id', true), '')::integer)
    );

How a request becomes database context

A policy predicate like tenant_id = current_setting('rls.tenant_id') is only half the machine. Something has to set rls.tenant_id before your queries run, and unset it after. That’s RLSContextMiddleware:

INSTALLED_APPS = [
    # ...
    'django_rls',
]

MIDDLEWARE = [
    # ... after AuthenticationMiddleware
    'django_rls.middleware.RLSContextMiddleware',
]

Per request, the middleware resets any stale RLS context left on the (possibly pooled) connection, reads the identity of the request, and writes it into PostgreSQL session variables via parameterized set_config() calls โ€” rls.user_id from the authenticated request.user, rls.tenant_id from request.tenant or the user’s profile. When the response is done โ€” success or exception, it’s in a finally โ€” the context is cleared. Policies read those variables with current_setting(), so the same SELECT * FROM myapp_document returns different rows depending on who is asking, with zero cooperation from view code.

Where the identity comes from is a trust boundary, and the 1.0 release got noticeably stricter about it. Session-stored tenant IDs are now off by default (a session value is client-adjacent state; you opt in with ALLOW_SESSION_TENANT and can plug in a tenant-membership validator that runs before the context is set). And once user_id or tenant_id is set for a scope, it is immutable โ€” an attempt to quietly reassign identity mid-request raises RLSContextImmutableError instead of succeeding. Privileged switches, for background jobs and tests, have to say so explicitly:

from django_rls.context import system_rls_context

# In a Celery task, where there is no request.user
with system_rls_context(user_id=uid, tenant_id=tid):
    Document.objects.filter(...)  # policies apply as this user/tenant

Anything beyond user and tenant โ€” a department ID, a region, the user’s IP โ€” goes through context processors: plain functions listed in RLS_CONTEXT_PROCESSORS that take the request and return a dict of extra context values, which policies can then read with RLS.context('department_id', IntegerField()). The mechanism is deliberately the same shape as Django’s template context processors, because you already know how those work.

Compiling Q objects into policies, and the three problems that hide there

ModelPolicy.get_compiled_sql() is the heart of the library, and it’s smaller than you’d guess: build a Query against the model, add_q() the filters, and compile the WHERE node with Django’s own compiler. Reusing Django’s compiler instead of writing a parallel Q-to-SQL translator was the single best decision in the codebase โ€” every lookup, every | and ~, every edge case Django’s ORM already handles correctly, django-rls inherits for free. But three real problems live between “compiles” and “correct.”

First: policies can’t join. A Postgres RLS policy is an expression evaluated against the candidate row โ€” you can’t write JOIN in a USING clause. But Django developers reach for joined lookups reflexively: Q(company__name='Acme'). So the compiler pass rewrites relation-crossing lookups into subqueries before compilation โ€” company__name='Acme' becomes company_id IN (SELECT id FROM company WHERE name = 'Acme'), which is legal inside a policy and predicate-equivalent. Anything deeper than the first hop stays inside the subquery, where Django’s ORM is allowed to join all it likes.

Second: current_setting() is a per-row tax if you’re careless. The naive predicate calls current_setting('rls.tenant_id') once per candidate row, and on a large table that shows up in profiles. Every context read django-rls emits is therefore wrapped in a scalar subquery โ€” (SELECT current_setting(...)::integer) โ€” which the Postgres planner evaluates once per statement as an InitPlan and treats as a constant thereafter. It’s the documented Postgres pattern for exactly this, and the library applies it uniformly across TenantPolicy, UserPolicy, and compiled ModelPolicy predicates so you don’t have to know it exists.

Third: this code path generates DDL, so injection is a design concern, not an afterthought. Field names and policy role names are validated against strict identifier patterns before they’re allowed anywhere near generated SQL โ€” they sit in identifier positions that cannot be parameterized, so a malformed value fails loudly at policy-definition time rather than reaching CREATE POLICY. Context values go through parameterized set_config(). And the CustomPolicy escape hatch runs its expression through a blocklist that rejects statement separators, comments, and DDL/DML keywords. There’s a dedicated tests/security/ suite โ€” context leaks across requests, SQL injection through context values and policy definitions, transaction-scope behavior, background-context misuse โ€” that landed alongside 1.0.

Two capability notes worth knowing before you assume they’re missing: custom user models work, including UUID primary keys โ€” RLS.user_id() resolves the user model’s actual PK type lazily and casts the context read to match โ€” and hierarchical tenancy (nested organizations, where access to a parent implies access to its subtree) is supported via recursive CTEs in the policy predicate.

The unglamorous parts that make it deployable

RLS has DDL lifecycle problems that a README quick-start can ignore and a production deployment cannot. Enabling RLS on a table, creating policies, changing them, removing them โ€” those are schema operations, and they belong in migrations like any other schema operation. django-rls ships custom migration operations (EnableRLS, DisableRLS, CreatePolicy, DropPolicy) so policy changes ride the same migrate pipeline as everything else, plus enable_rls / disable_rls management commands for operating on tables directly.

Two details in this layer matter more than they look. enable_rls() always applies FORCE ROW LEVEL SECURITY, not just ENABLE โ€” without FORCE, the table owner bypasses every policy, and most Django apps connect as the table owner, which turns the whole exercise into security theater. And because RLS failing open is invisible in a way RLS failing closed is not, there’s an audit_rls management command meant for CI: it verifies that RLS and FORCE RLS are actually active on every RLSModel table and flags raw-SQL CustomPolicy usage, so “we thought policies were enabled” gets caught before deploy rather than after an incident.

I’ll be honest about where the sharp edges still are, because the open issue tracker is public anyway: policy operations aren’t fully gated yet โ€” a SELECT-only policy can currently emit a WITH CHECK clause it shouldn’t, and the USING/WITH CHECK split by operation is being fixed. Boolean context values compile to text instead of casting properly. And the biggest requested feature is first-class task support โ€” Celery and friends run without a request.user, and while system_rls_context() covers it manually today, the ergonomic story for user-triggered versus background tasks deserves to be built in. That’s the near-term roadmap, in the order the issues are stacked.

Same idea, three frameworks

The core insight of django-rls โ€” request middleware sets Postgres session context, declarative policies compile to CREATE POLICY, the database enforces isolation โ€” is not actually Django-specific. Django is just where I built it first, because Django’s model Meta gave the policies a natural home. The same concept now exists as flask-rls and fastapi-rls, ported to each framework’s idioms; both have their own posts publishing alongside this one, and the project page tracks the family.

django-rls runs on Python 3.10โ€“3.13 and Django 5.0 through 6.0, against PostgreSQL 12 or newer, and it’s BSD-3-Clause โ€” pip install django-rls and you’re holding it. The docs at django-rls.com go deeper than this post, including a full enterprise-ERP tutorial that walks from basic tenant isolation through granular ACLs and hierarchical access. And if you’d rather break it with your own hands than read about it, that’s precisely what the DjangoCon Europe 2026 workshop is for: fifty Django developers, real policies, real Postgres, and the distinct pleasure of watching a query return zero rows because the database โ€” finally โ€” refused to trust the application.

The convention was “remember the filter.” The better contract is: you shouldn’t have to.