Row-Level Security
Row-Level Security (RLS) filters which rows each query may see or modify, evaluated per statement based on policies attached to tables.
Search across all documentation pages
Row-Level Security (RLS) filters which rows each query may see or modify, evaluated per statement based on policies attached to tables.
ALTER TABLE app.orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.orders FORCE ROW LEVEL SECURITY;
CREATE POLICY orders_tenant_isolation ON app.orders
FOR ALL
TO app_api
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);-- Application sets tenant per request (connection pool safe with SET LOCAL)
BEGIN;
SET LOCAL app.tenant_id = '42';
SELECT * FROM app.orders;
COMMIT;When to reach for this: Multi-tenant SaaS on a shared schema, defense-in-depth when app bugs could omit WHERE tenant_id, or regulatory separation within one database.
CREATE TABLE app.invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
amount numeric(12,2) NOT NULL
);
ALTER TABLE app.invoices ENABLE ROW LEVEL SECURITY;
-- Read-only policy for analysts
CREATE POLICY invoices_select ON app.invoices
FOR SELECT TO app_ro
USING (tenant_id = ANY (current_setting('app.allowed_tenants')::bigint[]));
-- App write policy
CREATE POLICY invoices_write ON app.invoices
FOR INSERT TO app_api
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
CREATE POLICY invoices_update ON app.invoices
FOR UPDATE TO app_api
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);What this demonstrates:
SELECT, INSERT, UPDATE) and roleUSING filters existing rows; WITH CHECK validates new/changed rowsSET LOCAL scopes GUC to transaction - safe with PgBouncer transaction poolingFOR clause | Applies to |
|---|---|
ALL | SELECT, INSERT, UPDATE, DELETE |
SELECT | Read visibility |
INSERT | WITH CHECK only |
UPDATE | USING + WITH CHECK |
DELETE | USING |
ALTER TABLE t ENABLE ROW LEVEL SECURITY; -- owner bypasses unless FORCE
ALTER TABLE t FORCE ROW LEVEL SECURITY; -- owner subject to policies tooFORCE is set.FORCE when owner is migration role only.-- Session variable (common with ORMs)
SET LOCAL app.tenant_id = '99';
-- JWT claim via custom GUC set in connection startup (application hook)
-- current_setting('request.jwt.claim.tenant_id', true)
-- Subquery policy for org hierarchy
USING (tenant_id IN (SELECT tenant_id FROM app.user_tenants WHERE user_id = current_user_id()))FORCE ROW LEVEL SECURITY on tenant tables.USING expression in WITH CHECK.SET without LOCAL leaks tenant across requests. Fix: Always SET LOCAL inside transaction.| Alternative | Use When | Don't Use When |
|---|---|---|
| Schema per tenant | Hard isolation, fewer cross-tenant queries | Thousands of tenants |
| Database per tenant | Maximum isolation | High operational overhead |
App-only WHERE tenant_id | Simple single-tenant | Compliance requires DB-enforced isolation |
Small when policy columns are indexed. See RLS Performance page for index patterns.
Superuser bypasses RLS unless FORCE and even then superuser still bypasses - use non-superuser app roles.
Set GUC, assert row counts per tenant fixture, attempt cross-tenant INSERT expecting failure.
FOR ALL is concise; separate policies allow different expressions per command and role.
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 18, 2026