Row-Level Security (RLS) is a powerful way to enforce tenant isolation directly inside YugabyteDB.
A typical multi-tenant policy might look like this:
CREATE POLICY tenant_isolation_policy
ON tenant_orders
USING (
tenant_id =
NULLIF(current_setting('app.tenant_id', true), '')::bigint
);
At first glance, it can be tempting to think of the policy as an authorization check that YugabyteDB performs once when the query starts:
Can this user access tenant 101?
Yes β Run the query
No β Reject the query
But that isn’t the right mental model.
An RLS policy becomes part of the query’s row-filtering logic. Conceptually, YugabyteDB is evaluating:
Can this row be seen? Can this row be seen? Can this row be seen? β¦
That distinction becomes very important when designing RLS policies for large tables.
The source material behind this tip highlights exactly this behavior: RLS expressions operate at the row level, which can turn expensive parsing or functions with side effects into a significant multiplier.
RLS Lives in the Query’s Hot Path
Consider:
SELECT COUNT(*)
FROM tenant_orders;
If RLS applies to the querying role, rows must satisfy the RLS predicate before they are visible to the query.
That means anything placed inside the policy potentially becomes part of a very frequently executed code path.
For example, this is relatively lightweight:
USING (
tenant_id =
NULLIF(current_setting('app.tenant_id', true), '')::bigint
);
But consider this:
USING (
tenant_id = ANY (
string_to_array(
current_setting('app.tenant_ids', true),
','
)::bigint[]
)
);
Or something even more expensive:
USING (
some_expensive_function(tenant_id)
);
Or a function with a side effect:
USING (
log_access_and_return_true()
);
The more work placed inside the RLS predicate, the more important its execution characteristics become.
β‘ Think of an RLS USING clause as hot-path code.
Keep the predicate simple, deterministic where possible, and index-friendly. Small amounts of extra work can become expensive when applied repeatedly across many rows.
There Isn’t a Separate “RLS Result Cache”
It is better not to think of RLS like this:
Evaluate policy once
β
Cache TRUE
β
Process the entire query
RLS predicates participate in normal PostgreSQL/YSQL query planning and execution.
The optimizer may be able to:
β Simplify expressions
β Reorder or push down predicates
β Use indexes
β Avoid unnecessary work
β Optimize function evaluation based on volatility
But you should not design an RLS policy assuming that YugabyteDB simply evaluates the policy once per SQL statement and caches the answer.
This is where PostgreSQL function volatility becomes especially important.
VOLATILE vs. STABLE vs. IMMUTABLE
PostgreSQL and YSQL functions can be classified as:
β VOLATILE
β STABLE
β IMMUTABLE
These classifications are more than documentation.
They tell the query optimizer what assumptions it is allowed to make about the function.
Here is a useful summary:
Classification
Meaning
RLS Consideration
VOLATILE
Result may change every time the function is called.
Potentially expensive inside frequently evaluated RLS predicates, especially when it performs writes or other side effects.
STABLE
Result is guaranteed not to change within a single SQL statement.
Often appropriate for functions reading transaction/session context such as tenant GUCs.
IMMUTABLE
The same arguments must always produce the same result.
Allows aggressive optimization, but should not be used for session-dependent values unless that promise is actually true.
Using a STABLE Function for Tenant Context
Suppose your application supports access to multiple tenants and stores the allowed tenant IDs in a custom GUC:
SET LOCAL app.tenant_ids = '101,102,105';
Instead of embedding all of the parsing logic directly in the RLS policy:
CREATE POLICY tenant_isolation_policy
ON tenant_orders
USING (
tenant_id = ANY (
string_to_array(
current_setting('app.tenant_ids', true),
','
)::bigint[]
)
);
you can encapsulate the tenant-context logic:
CREATE OR REPLACE FUNCTION current_tenant_ids()
RETURNS bigint[]
LANGUAGE sql
STABLE
AS $$
SELECT
string_to_array(
NULLIF(current_setting('app.tenant_ids', true), ''),
','
)::bigint[];
$$;
Then simplify the RLS policy:
CREATE POLICY tenant_isolation_policy
ON tenant_orders
USING (
tenant_id = ANY(current_tenant_ids())
);
This has several benefits.
It keeps complex parsing out of the policy itself, gives the optimizer useful information about the function’s volatility, and makes the security policy easier to read and maintain.
But there is an important caveat.
π‘ STABLE does not mean “execute exactly once.”
STABLE tells the PostgreSQL/YSQL optimizer that the function’s result will not change during the statement. That gives the optimizer additional freedom, but you should still inspect the execution plan rather than assume a particular number of function calls.
Be Careful Using IMMUTABLE with Tenant GUCs
You may encounter techniques that wrap current_setting() inside an IMMUTABLE function to encourage more aggressive optimization.
For example:
CREATE OR REPLACE FUNCTION get_tenant_ids()
RETURNS bigint[]
LANGUAGE sql
IMMUTABLE
AS $$
SELECT
string_to_array(
current_setting('app.tenant_ids'),
','
)::bigint[];
$$;
This is attractive because IMMUTABLE gives the optimizer much stronger guarantees.
But think carefully about what IMMUTABLE actually promises:
The result cannot change.
A session setting clearly can.
For example:
BEGIN;
SET LOCAL app.tenant_id = '101';
-- queries for tenant 101
COMMIT;
and then:
BEGIN;
SET LOCAL app.tenant_id = '205';
-- queries for tenant 205
COMMIT;
The value returned by current_setting() changed.
This distinction becomes especially important when using:
β Prepared statements
β Reused query plans
β Connection pools
β YSQL Connection Manager
β Applications switching tenant context between transactions
Labeling a function as IMMUTABLE when it really depends on session state violates the contract you are giving the optimizer.
β οΈ Don’t mark a function IMMUTABLE just to make it faster.
Function volatility is a contract with the optimizer. For tenant context obtained from current_setting(), STABLE generally better describes the function’s semantics because the value can change between statements or transactions.
The More Dangerous RLS Trap: Side Effects
Repeated CPU work is one concern.
Functions that perform writes are potentially much more dangerous.
This connects directly to a previous YugabyteDB Tip:
Repeated computation can increase query latency as more rows are evaluated.
I/O Multiplier
Audit inserts, updates, logging functions, functions with other side effects
A read query can generate a large amount of additional write activity.
But “Per Row” Does NOT Necessarily Mean “Scan Every Row”
This is an extremely important distinction.
Saying that an RLS predicate applies at the row level does not mean YugabyteDB must always read every row in the table and then throw most of them away.
Consider a table designed like this:
CREATE TABLE tenant_orders (
tenant_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
order_detail TEXT,
PRIMARY KEY (tenant_id, order_id)
);
And a policy:
CREATE POLICY tenant_isolation_policy
ON tenant_orders
USING (
tenant_id =
NULLIF(current_setting('app.tenant_id', true), '')::bigint
);
Now:
BEGIN;
SET LOCAL app.tenant_id = '101';
SELECT *
FROM tenant_orders;
COMMIT;
The ideal result is for the tenant predicate to participate in the access path so YugabyteDB can retrieve the relevant tenant’s rows efficiently rather than scanning unrelated tenants.
This is one reason the shape of your RLS predicate matters just as much as the cost of evaluating it.
A simple predicate like:
tenant_id =
aligned with an appropriate primary key or index is much easier to optimize than complex policy logic involving functions, parsing, joins, or subqueries.
Always Check the Execution Plan
Don’t guess.
Test the policy with:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT *
FROM tenant_orders;
But make sure you test as the actual application role.
Testing as one of these can give you very misleading results:
β Superuser
β Table owner
β Role with BYPASSRLS
because those users may bypass RLS.
For example:
SET ROLE tenant_app;
BEGIN;
SET LOCAL app.tenant_id = '101';
EXPLAIN (ANALYZE, COSTS OFF)
SELECT *
FROM tenant_orders;
ROLLBACK;
Look for whether YugabyteDB can use the tenant predicate efficiently as part of the access path rather than processing significantly more data than necessary.
A Good RLS Pattern
For applications where each transaction operates on one tenant, a simple pattern is difficult to beat:
CREATE POLICY tenant_isolation_policy
ON tenant_orders
FOR ALL
TO tenant_app
USING (
tenant_id =
NULLIF(current_setting('app.tenant_id', true), '')::bigint
)
WITH CHECK (
tenant_id =
NULLIF(current_setting('app.tenant_id', true), '')::bigint
);
Then establish the tenant context at the beginning of the transaction:
BEGIN;
SET LOCAL app.tenant_id = '101';
-- Application queries
COMMIT;
This gives you:
Simple RLS predicate + Indexable tenant_id + Fail-closed tenant context + Transaction-scoped GUC + No policy side effects
This helps the policy fail closed if the tenant context is missing or empty.
current_setting(..., true) returns NULL when the setting does not exist, while:
NULLIF(..., '')
also converts an empty string to NULL.
The resulting comparison:
tenant_id = NULL
does not evaluate to TRUE.
So instead of accidentally exposing another tenant’s rows, the policy returns no matching rows.
π Security and performance can reinforce each other.
A fail-closed tenant GUC combined with a simple, indexable tenant_id predicate gives YugabyteDB both a strong security boundary and a predicate the query engine has a good chance of executing efficiently.
How This Fits with Previous YugabyteDB Tips
There are two related YugabyteDB Tips worth reading with this one.
Demonstrates the clever RLS auditing techniqueβand provides a perfect example of why side effects inside a row-level predicate must be used carefully.
Final Takeaway
The most important thing to remember about RLS performance is this:
RLS is a predicate, not a one-time authorization check.
Treat the policy expression like code running in the hot path of your query.
over complicated parsing, lookup, or side-effect logic whenever possible.
The practical rules are:
β Keep RLS predicates simple.
β Make tenant predicates index-friendly.
β Use fail-closed tenant context.
β Use the correct function volatility classification.
β Treat VOLATILE functions inside policies with caution.
β Avoid writes from RLS policies on high-volume reads unless the per-row behavior is explicitly what you want.
β Don’t assume STABLE means “executed exactly once.”
β Don’t declare something IMMUTABLE merely to encourage caching.
β Use EXPLAIN ANALYZE with the actual application role.
β Remember that row-level evaluation does not necessarily mean a full-table scan when the predicate can be used efficiently as an access condition.
π‘ The goal isn’t to avoid RLS.
RLS is an excellent security mechanism for multi-tenant applications. The goal is to design the policy so that the row-level security check is cheap, predictable, and aligned with the way YugabyteDB accesses the data.
Have Fun!
Iβm in Sunnyvale, CA this week for an offsite with the YugabyteDB engineering team. After a full day of meetings, a colleague and I grabbed dinner at Black Bear Diner.
What a cool place! It brought back some great memories of the classic diners back home around Pittsburgh, PA. The food was incredible, the atmosphere was fun, and yeahβ¦ they definitely lean into the bear theme inside. π»π
Great food, great company, and a little unexpected taste of home while 2,500 miles away.