<!-- mobian-agent-page publisher="dailydev" canonical="https://daily.dev/posts/row-level-security-performance-in-postgresql-measured-ymgclktel" -->

---
title: Row-level security performance in PostgreSQL, measured
description: A benchmark on PostgreSQL 17.10 measures exactly what row-level security policies cost under different implementations. A simple equality policy is free when...
canonical: https://daily.dev/posts/row-level-security-performance-in-postgresql-measured-ymgclktel
twitter:card: summary_large_image
twitter:site: @dailydotdev
og:type: website
og:site_name: daily.dev
og:title: Row-level security performance in PostgreSQL, measured | daily.dev
og:description: A benchmark on PostgreSQL 17.10 measures exactly what row-level security policies cost under different implementations. A simple equality policy is free when...
og:url: https://daily.dev/posts/row-level-security-performance-in-postgresql-measured-ymgclktel
og:image: https://api.daily.dev/og/posts/yMGcLkTEL.png
og:image:alt: Row-level security performance in PostgreSQL, measured
og:image:width: 1200
og:image:height: 630
og:locale: en
---

> ## Documentation Index
> Fetch the complete documentation index at: https://daily.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Row-level security performance in PostgreSQL, measured

**[Planet PostgreSQL](https://daily.dev/sources/planet-postgresql)** · 10 min read · 0 upvotes · 0 comments

## Summary

A benchmark on PostgreSQL 17.10 measures exactly what row-level security policies cost under different implementations. A simple equality policy is free when paired with the right index, but costs balloon depending on how the tenant is resolved, whether membership is checked via subquery, and whether non-leakproof functions like lower() or LIKE are used. A PL/pgSQL function declared VOLATILE (the default) forces a sequential scan, turning a sub-millisecond query into nearly 2 seconds; SECURITY DEFINER or SET search_path break SQL function inlining similarly. Membership subqueries using IN add ~80ms per query regardless of tenant size, while resolving membership once per request avoids this. The leakproof rule silently disables index use for functions like lower() and LIKE, hitting large tenants hardest (34-45ms vs 0.2ms) while hiding from tests with small tenants; fixes include STABLE declarations, generated columns, and text_pattern_ops operators. Includes SQL queries to detect volatile-function policies and advice to EXPLAIN as the application role, not the table owner.

## Full article

daily.dev links to this article rather than hosting it. Read it at the original source: <https://postgr.es/p/9wE>

## Questions this post answers

### why does my PostgreSQL row-level security policy force a sequential scan instead of using my index

A PL/pgSQL helper function used in the policy is VOLATILE by default, which cannot be inlined and must be evaluated once per row, disabling index use entirely. On a 2-million-row table this turned a sub-millisecond lookup into roughly 1,860-1,920 ms. Declaring the function STABLE fixes it, or wrapping the call in (select ...) forces it to compute once per query instead of once per row.

_Developers debugging RLS slowdowns can dig into these PostgreSQL performance traces and fixes on daily.dev._

### why does lower(customer_email) in a WHERE clause get slow only for my largest tenant under row-level security

PostgreSQL's leakproof rule forces non-leakproof functions like lower() or LIKE to run only after the RLS policy filters rows, so the index is used solely for the tenant_id condition and lower() gets evaluated row by row as a Filter. On a 267,023-row tenant this took 34-45 ms versus 0.21 ms without RLS, while a 534-row median tenant barely noticed at 0.38-0.40 ms, hiding the problem in small test tenants. A stored generated column with lower(customer_email) restored performance to 0.25 ms.

_Teams sizing multi-tenant Postgres workloads can track these leakproof-function gotchas on daily.dev before they hit production._

### what is the performance cost of a membership subquery in a PostgreSQL row-level security policy

Using tenant_id in (select tenant_id from memberships where user_id = ...) inside an RLS policy costs about 80 milliseconds on every query regardless of tenant size, because the planner checks each row against the membership list instead of using an index lookup. Rewriting it as tenant_id = any(array(select ...)) fixes counts but can worsen limited queries (158 ms for a top tenant's latest 50). Resolving membership once per request and storing a single tenant value in the policy avoids the cost entirely.

_Engineers designing multi-tenant access control can compare these RLS policy patterns and their trade-offs on daily.dev._

## Similar posts on daily.dev

- [PostgreSQL indexes for multi-tenant SaaS: tenant\_id first](https://daily.dev/posts/postgresql-indexes-for-multi-tenant-saas-tenant-id-first-hyoppz186) · Planet PostgreSQL · 0 upvotes · 0 comments
- [Announcing Functional Index Support in Doltgres](https://daily.dev/posts/announcing-functional-index-support-in-doltgres-jq353usja) · DoltHub Blog · 2 upvotes · 0 comments
- [How to Use PostgreSQL as a Cache, Queue, and Search Engine](https://daily.dev/posts/how-to-use-postgresql-as-a-cache-queue-and-search-engine-ykurnyxgc) · freeCodeCamp · 90 upvotes · 1 comments

---

Tags: [#database](https://daily.dev/tags/database), [#postgresql](https://daily.dev/tags/postgresql)

[View this post on daily.dev](https://daily.dev/posts/row-level-security-performance-in-postgresql-measured-ymgclktel)

```json
{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://daily.dev/#organization","name":"daily.dev","url":"https://daily.dev","logo":{"@type":"ImageObject","url":"https://daily.dev/apple-touch-icon.png","width":180,"height":180},"sameAs":["https://twitter.com/dailydotdev","https://github.com/dailydotdev","https://www.linkedin.com/company/daily-dev-ltd"]},{"@type":"WebSite","@id":"https://daily.dev/#website","url":"https://daily.dev","name":"daily.dev","publisher":{"@id":"https://daily.dev/#organization"},"potentialAction":{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https://daily.dev/search?q={search_term_string}"},"query-input":"required name=search_term_string"}}]}
{"@context":"https://schema.org","@type":"TechArticle","headline":"Row-level security performance in PostgreSQL, measured","url":"https://daily.dev/posts/row-level-security-performance-in-postgresql-measured-ymgclktel","mainEntityOfPage":{"@type":"WebPage","@id":"https://daily.dev/posts/row-level-security-performance-in-postgresql-measured-ymgclktel"},"datePublished":"2026-09-30T23:32:45.303Z","dateModified":"2026-09-30T23:33:10.976Z","description":"A benchmark on PostgreSQL 17.10 measures exactly what row-level security policies cost under different implementations. A simple equality policy is free when...","image":"https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/fdeacaf2a945e58380479dc8aaeeee27?_a=AQAEuop","thumbnailUrl":"https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/fdeacaf2a945e58380479dc8aaeeee27?_a=AQAEuop","isAccessibleForFree":true,"articleSection":"Planet PostgreSQL","inLanguage":"en","publisher":{"@type":"Organization","name":"daily.dev","url":"https://daily.dev","logo":{"@type":"ImageObject","url":"https://daily.dev/apple-touch-icon.png","width":180,"height":180}},"author":{"@type":"Organization","name":"Planet PostgreSQL","logo":"https://media.daily.dev/image/upload/logos/placeholder.jpg","url":"https://daily.dev/sources/planet-postgresql"},"commentCount":0,"discussionUrl":"https://daily.dev/posts/row-level-security-performance-in-postgresql-measured-ymgclktel","interactionStatistic":[{"@type":"InteractionCounter","interactionType":{"@type":"LikeAction"},"userInteractionCount":0},{"@type":"InteractionCounter","interactionType":{"@type":"CommentAction"},"userInteractionCount":0}],"keywords":"database,postgresql","timeRequired":"PT10M"}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://daily.dev"},{"@type":"ListItem","position":2,"name":"Planet PostgreSQL","item":"https://daily.dev/sources/planet-postgresql"},{"@type":"ListItem","position":3,"name":"Row-level security performance in PostgreSQL, measured"}]}
{"@context":"https://schema.org","@type":"FAQPage","@id":"https://daily.dev/posts/row-level-security-performance-in-postgresql-measured-ymgclktel#faq","mainEntity":[{"@type":"Question","name":"why does my PostgreSQL row-level security policy force a sequential scan instead of using my index","acceptedAnswer":{"@type":"Answer","text":"A PL/pgSQL helper function used in the policy is VOLATILE by default, which cannot be inlined and must be evaluated once per row, disabling index use entirely. On a 2-million-row table this turned a sub-millisecond lookup into roughly 1,860-1,920 ms. Declaring the function STABLE fixes it, or wrapping the call in (select ...) forces it to compute once per query instead of once per row. Developers debugging RLS slowdowns can dig into these PostgreSQL performance traces and fixes on daily.dev."}},{"@type":"Question","name":"why does lower(customer_email) in a WHERE clause get slow only for my largest tenant under row-level security","acceptedAnswer":{"@type":"Answer","text":"PostgreSQL's leakproof rule forces non-leakproof functions like lower() or LIKE to run only after the RLS policy filters rows, so the index is used solely for the tenant_id condition and lower() gets evaluated row by row as a Filter. On a 267,023-row tenant this took 34-45 ms versus 0.21 ms without RLS, while a 534-row median tenant barely noticed at 0.38-0.40 ms, hiding the problem in small test tenants. A stored generated column with lower(customer_email) restored performance to 0.25 ms. Teams sizing multi-tenant Postgres workloads can track these leakproof-function gotchas on daily.dev before they hit production."}},{"@type":"Question","name":"what is the performance cost of a membership subquery in a PostgreSQL row-level security policy","acceptedAnswer":{"@type":"Answer","text":"Using tenant_id in (select tenant_id from memberships where user_id = ...) inside an RLS policy costs about 80 milliseconds on every query regardless of tenant size, because the planner checks each row against the membership list instead of using an index lookup. Rewriting it as tenant_id = any(array(select ...)) fixes counts but can worsen limited queries (158 ms for a top tenant's latest 50). Resolving membership once per request and storing a single tenant value in the policy avoids the cost entirely. Engineers designing multi-tenant access control can compare these RLS policy patterns and their trade-offs on daily.dev."}}]}
```

