<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:taxo="http://purl.org/rss/1.0/modules/taxonomy/" version="2.0">
  <channel>
    <title>topic Re: Row-level security when the filter column is not on your table in Community Articles</title>
    <link>https://community.databricks.com/t5/community-articles/row-level-security-when-the-filter-column-is-not-on-your-table/m-p/169545#M1593</link>
    <description>&lt;P&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/216690"&gt;@Ashwin_DSA&lt;/a&gt;&amp;nbsp;&lt;/P&gt;&lt;P&gt;Good to see this post here as we landed on the same pattern a few months back after testing a few approaches and looping in the Databricks team to sense-check whether we were missing anything.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Here's what we observed:&lt;/STRONG&gt;&lt;/P&gt;&lt;UL&gt;&lt;LI&gt;&lt;STRONG&gt;Started with a direct LEFT SEMI JOIN&lt;/STRONG&gt; on the control table, got SortMergeJoin with two full shuffle exchanges across 200 partitions on both sides. &lt;STRONG&gt;Contributing factor:&lt;/STRONG&gt; missing table statistics, the optimizer had no idea the per-user result was small.&amp;nbsp;&lt;/LI&gt;&lt;/UL&gt;&lt;LI-CODE lang="markup"&gt;FROM wfm_site s
LEFT SEMI JOIN wfm_user_site_mapping m
    ON  s.site_id    = m.site_id
    AND m.user_email = lower(current_user())
-- Physical plan: SortMergeJoin
-- Exchange hashpartitioning(site_id, 200)  ← shuffle
​&lt;/LI-CODE&gt;&lt;UL&gt;&lt;LI&gt;&lt;STRONG&gt;Switched to a SQL UDF with EXISTS&lt;/STRONG&gt;: the plan moved to BroadcastHashJoin LeftSemi. Since SQL UDFs are inlined at planning time, Photon treats it as a native join with no ColumnarToRow transition, unlike a Python UDF which would have shown BatchEvalPython and fallen back on every row.&lt;/LI&gt;&lt;/UL&gt;&lt;LI-CODE lang="markup"&gt;CREATE OR REPLACE FUNCTION fx_row_filter_wfm_site(site_id LONG)
RETURNS BOOLEAN
RETURN EXISTS (
    SELECT /*+ BROADCAST(m) */ 1
    FROM   wfm_user_site_mapping m
    WHERE  m.user_email = lower(current_user())
      AND  m.site_id    = fx_row_filter_wfm_site.site_id
)&lt;/LI-CODE&gt;&lt;UL&gt;&lt;LI&gt;The broadcast is &lt;STRONG&gt;EXECUTOR_BROADCAST&lt;/STRONG&gt; driven by AQE, not hint-driven. The user_email = current_user() predicate pushes all the way to the parquet scan, so the per-user rows are already small before the exchange happens.&amp;nbsp;&amp;nbsp;&lt;/LI&gt;&lt;/UL&gt;&lt;LI-CODE lang="markup"&gt;Confirmed in physical plan: 
Scan parquet wfm_user_site_mapping 
PushedFilters: [EqualTo(user_email, &amp;lt;current_user&amp;gt;), IsNotNull(site_id)]&lt;/LI-CODE&gt;&lt;UL&gt;&lt;LI&gt;Ran ANALYZE TABLE .. COMPUTE STATISTICS FOR ALL COLUMNS after each mapping table rebuild, this made the broadcast strategy consistent across both the UDF and direct JOIN approaches.&amp;nbsp;&lt;/LI&gt;&lt;/UL&gt;&lt;LI-CODE lang="markup"&gt;ANALYZE TABLE wfm_user_site_mapping
COMPUTE STATISTICS FOR ALL COLUMNS&lt;/LI-CODE&gt;&lt;P&gt;&lt;U&gt;&lt;STRONG&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/249473"&gt;@ivanvyd&lt;/a&gt;&amp;nbsp;&lt;/STRONG&gt;&lt;/U&gt;&lt;/P&gt;&lt;P&gt;&lt;U&gt;&lt;STRONG&gt;On the scaling front:&lt;/STRONG&gt;&lt;/U&gt;&lt;/P&gt;&lt;P&gt;The per-user predicate pushdown is what keeps the broadcast small regardless of overall table size. We confirmed it in the physical plan, PushedFilters: [EqualTo(user_email, &amp;lt;current_user&amp;gt;)] appears at the scan node, not after. We also split into separate lean tables per dimension (one for site, one for contract) to avoid cross-join bloat from contract-to-site relationships inflating the broadcast payload.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;&lt;EM&gt;One thing worth flagging:&lt;/EM&gt;&lt;/STRONG&gt; a unified two-param nullable function (site_id IS NULL OR ..) was noticeably slower than a single-param function per dimension. The &lt;STRONG&gt;IS NULL OR&lt;/STRONG&gt; pattern blocks predicate pushdown, Spark could't push a partial OR condition as a simple equality filter to the parquet scan, so it falls back to filtering in memory after the scan. Plain equality predicates pushed cleanly.&lt;/P&gt;&lt;LI-CODE lang="markup"&gt;-- IS NULL OR blocks predicate pushdown
CREATE FUNCTION fx_row_filter_wfm(site_id LONG, contract_id LONG)
RETURNS BOOLEAN
RETURN EXISTS (
    SELECT 1 FROM wfm_user_permission_mapping m
    WHERE  m.user_email = lower(current_user())
      AND (site_id     IS NULL OR m.site_id     = fx_row_filter_wfm.site_id)
      AND (contract_id IS NULL OR m.contract_id = fx_row_filter_wfm.contract_id)
    -- Spark couldn't push a partial OR condition to parquet scan
);

-- Plain equality → full predicate pushdown to scan
CREATE FUNCTION fx_row_filter_wfm_site(site_id LONG)
RETURNS BOOLEAN
RETURN EXISTS (
    SELECT 1 FROM wfm_user_site_mapping m
    WHERE  m.user_email = lower(current_user())
      AND  m.site_id    = fx_row_filter_wfm_site.site_id
    -- PushedFilters: [EqualTo(user_email,...), IsNotNull(site_id)]
);&lt;/LI-CODE&gt;&lt;P&gt;&lt;STRONG&gt;On scale:&amp;nbsp;&lt;/STRONG&gt;we also tested this on much larger tables (~2B and ~18B rows) with the same row filter enforced. The overhead scales with how much data the user is permitted to see, not the total table size. Users with narrow access actually outperformed broader-access users on larger tables, fewer permitted keys means fewer matching files, so file pruning kicks in more aggressively at the scan.&amp;nbsp; A narrow-access restricted user on an 18B row table came in faster than a broad-access user on a 2B row table across repeated runs.&lt;/P&gt;</description>
    <pubDate>Wed, 23 Sep 2026 09:18:19 GMT</pubDate>
    <dc:creator>data_pulse</dc:creator>
    <dc:date>2026-09-23T09:18:19Z</dc:date>
    <item>
      <title>Row-level security when the filter column is not on your table</title>
      <link>https://community.databricks.com/t5/community-articles/row-level-security-when-the-filter-column-is-not-on-your-table/m-p/169339#M1587</link>
      <description>&lt;DIV style="max-width: 860px; margin: 0 auto; padding: 0 32px 80px; background: #FFFFFF; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color: #1b3139; line-height: 1.75; font-size: 17px;"&gt;
&lt;P&gt;A common question when setting up row-level security in Unity Catalog is what to do when the column that decides who can see a row is not on the table you want to protect. The value lives in a related table, one join away. This comes up often in insurance, where a claims table is keyed by policy, but which agent services which policy is held in a separate mapping table.&lt;/P&gt;
&lt;P&gt;This post walks through that situation. It shows why the first idea does not work, the pattern that does, and a way to set it up so one small control table drives the same rule across many tables. Every table and column name here is a plain example you can rename to your own.&lt;/P&gt;
&lt;DIV style="background: #F0F4F6; border-left: 4px solid #FF3621; padding: 20px 24px; margin: 28px 0; border-radius: 6px;"&gt;
&lt;DIV style="font-size: 12px; font-weight: bold; color: #ff3621; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 12px;"&gt;Key takeaways&lt;/DIV&gt;
&lt;UL style="margin: 0; padding-left: 20px; color: #1b3139;"&gt;
&lt;LI style="margin-bottom: 8px;"&gt;A row filter is a SQL function, so it can hold a subquery. That lets you scope a table by a value that lives in a related table.&lt;/LI&gt;
&lt;LI style="margin-bottom: 8px;"&gt;You cannot filter a table and also read that same table inside another table's filter. Unity Catalog stops it with an error.&lt;/LI&gt;
&lt;LI style="margin-bottom: 8px;"&gt;Keep the lookup in a small control table that is never filtered, and point every table's filter at it.&lt;/LI&gt;
&lt;LI style="margin-bottom: 0;"&gt;The filter reads the control table under the owner's rights, so users need no access to it. Onboarding a user is a one row change.&lt;/LI&gt;
&lt;/UL&gt;
&lt;/DIV&gt;
&lt;DIV style="background: #FAFBFC; border: 1px solid #E8ECF0; border-radius: 8px; padding: 20px 28px; margin: 24px 0 16px;"&gt;
&lt;DIV style="font-size: 13px; font-weight: bold; color: #ff3621; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 12px;"&gt;What is in this post&lt;/DIV&gt;
&lt;OL style="margin: 0; padding-left: 20px;"&gt;
&lt;LI style="margin-bottom: 6px; font-size: 15px;"&gt;&lt;A style="color: #1b3139; text-decoration: none;" href="#problem" target="_blank"&gt;The problem&lt;/A&gt;&lt;/LI&gt;
&lt;LI style="margin-bottom: 6px; font-size: 15px;"&gt;&lt;A style="color: #1b3139; text-decoration: none;" href="#how" target="_blank"&gt;How row filters and ABAC work&lt;/A&gt;&lt;/LI&gt;
&lt;LI style="margin-bottom: 6px; font-size: 15px;"&gt;&lt;A style="color: #1b3139; text-decoration: none;" href="#why-not" target="_blank"&gt;Why filtering the mapping table does not work&lt;/A&gt;&lt;/LI&gt;
&lt;LI style="margin-bottom: 6px; font-size: 15px;"&gt;&lt;A style="color: #1b3139; text-decoration: none;" href="#pattern" target="_blank"&gt;How to scope the fact table&lt;/A&gt;&lt;/LI&gt;
&lt;LI style="margin-bottom: 6px; font-size: 15px;"&gt;&lt;A style="color: #1b3139; text-decoration: none;" href="#control" target="_blank"&gt;One control table for many tables&lt;/A&gt;&lt;/LI&gt;
&lt;LI style="margin-bottom: 6px; font-size: 15px;"&gt;&lt;A style="color: #1b3139; text-decoration: none;" href="#read-map" target="_blank"&gt;When users also need to read the mapping table&lt;/A&gt;&lt;/LI&gt;
&lt;LI style="margin-bottom: 6px; font-size: 15px;"&gt;&lt;A style="color: #1b3139; text-decoration: none;" href="#checking" target="_blank"&gt;Checking it works&lt;/A&gt;&lt;/LI&gt;
&lt;LI style="margin-bottom: 0; font-size: 15px;"&gt;&lt;A style="color: #1b3139; text-decoration: none;" href="#keep-in-mind" target="_blank"&gt;Things to keep in mind&lt;/A&gt;&lt;/LI&gt;
&lt;/OL&gt;
&lt;/DIV&gt;
&lt;H2 id="problem" style="font-size: 26px; font-weight: bold; color: #1b3139; margin: 36px 0 16px; padding-bottom: 8px; border-bottom: 3px solid #FF3621; display: inline-block;"&gt;The problem&lt;/H2&gt;
&lt;P&gt;Here is the model. There are three tables.&lt;/P&gt;
&lt;UL style="padding-left: 24px; margin: 0 0 16px;"&gt;
&lt;LI style="margin-bottom: 8px;"&gt;&lt;STRONG&gt;The claims fact table&lt;/STRONG&gt;, &lt;CODE&gt;claim_fact&lt;/CODE&gt;. One row per claim, keyed by &lt;CODE&gt;policy_id&lt;/CODE&gt;. It does not carry an agent id.&lt;/LI&gt;
&lt;LI style="margin-bottom: 8px;"&gt;&lt;STRONG&gt;The mapping table&lt;/STRONG&gt;, &lt;CODE&gt;policy_agent_map&lt;/CODE&gt;. One row per policy an agent services, holding &lt;CODE&gt;policy_id&lt;/CODE&gt; and &lt;CODE&gt;agent_id&lt;/CODE&gt;.&lt;/LI&gt;
&lt;LI style="margin-bottom: 8px;"&gt;&lt;STRONG&gt;The directory&lt;/STRONG&gt;, &lt;CODE&gt;agent_directory&lt;/CODE&gt;. Maps the signed-in user, &lt;CODE&gt;user_email&lt;/CODE&gt;, to their &lt;CODE&gt;agent_id&lt;/CODE&gt;.&lt;/LI&gt;
&lt;/UL&gt;
&lt;P&gt;The goal is that an agent sees only the claims for the policies they service, and can also read the mapping table and see only their own policies. The value that decides all of this, the agent id, is two joins away from the claims table.&lt;/P&gt;
&lt;FIGURE style="margin: 20px 0;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="diagram_1_setup.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31343i98CF1D6236355FAE/image-size/large?v=v2&amp;amp;px=999" role="button" title="diagram_1_setup.png" alt="diagram_1_setup.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;FIGCAPTION style="font-size: 13px; color: #6b8a97; margin-top: 8px; font-style: italic; text-align: center;"&gt;The claims table reaches its entitlement two joins away, through the mapping table to the directory, based on the signed-in user.&lt;/FIGCAPTION&gt;
&lt;/FIGURE&gt;
&lt;FIGURE style="margin: 20px 0; text-align: center;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="shot_schema.png" style="width: 400px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31345iDA2C6E67248921D1/image-size/medium?v=v2&amp;amp;px=400" role="button" title="shot_schema.png" alt="shot_schema.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;P&gt;&amp;nbsp;&lt;/P&gt;
&lt;FIGCAPTION style="font-size: 13px; color: #6b8a97; margin-top: 8px; font-style: italic; text-align: center;"&gt;The objects in the schema. The claims fact and the mapping table, the directory and the control table, and the two filter functions.&lt;/FIGCAPTION&gt;
&lt;/FIGURE&gt;
&lt;H2 id="how" style="font-size: 26px; font-weight: bold; color: #1b3139; margin: 36px 0 16px; padding-bottom: 8px; border-bottom: 3px solid #FF3621; display: inline-block;"&gt;How row filters and ABAC work&lt;/H2&gt;
&lt;P&gt;A row filter in Unity Catalog is a SQL function bound to a column. It runs for every row and returns true or false. If it returns true, the row is shown. A simple filter on a table that already has the agent id looks like this.&lt;/P&gt;
&lt;PRE style="background: #1B3139; border-left: 4px solid #FF3621; border-radius: 6px; padding: 20px 24px; margin: 20px 0; font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace; font-size: 13.5px; line-height: 1.6; color: #e8ecf0; overflow-x: auto;"&gt;&lt;SPAN&gt;CREATE OR REPLACE FUNCTION&lt;/SPAN&gt; scope_by_agent(agent_id &lt;SPAN&gt;STRING&lt;/SPAN&gt;)
  &lt;SPAN&gt;RETURN&lt;/SPAN&gt; is_account_group_member(&lt;SPAN&gt;'claims_supervisors'&lt;/SPAN&gt;)
      &lt;SPAN&gt;OR&lt;/SPAN&gt; agent_id = current_user();

&lt;SPAN&gt;ALTER TABLE&lt;/SPAN&gt; agent_book &lt;SPAN&gt;SET ROW FILTER&lt;/SPAN&gt; scope_by_agent &lt;SPAN&gt;ON&lt;/SPAN&gt; (agent_id);&lt;/PRE&gt;
&lt;P&gt;Attribute-based access control, ABAC, does the same job through a governed tag on a column plus a policy. You tag the column once, write one policy, and it covers every table that carries that tag, including tables added later. That is the better fit when the same rule applies across many tables. The row filter idea below works the same way whether you bind it to one table by hand or apply it through a tag and policy.&lt;/P&gt;
&lt;H2 id="why-not" style="font-size: 26px; font-weight: bold; color: #1b3139; margin: 36px 0 16px; padding-bottom: 8px; border-bottom: 3px solid #FF3621; display: inline-block;"&gt;Why filtering the mapping table does not work&lt;/H2&gt;
&lt;P&gt;The first idea most people reach for is to put a row filter on the mapping table, then have the claims filter read that mapping table to find the agent's policies. This does not work.&lt;/P&gt;
&lt;P&gt;Unity Catalog does not allow a table that already has an active row filter or column mask to be read inside another table's filter. The query fails with the error &lt;CODE&gt;UNSUPPORTED_NESTED_ROW_OR_COLUMN_ACCESS_POLICY&lt;/CODE&gt;. So the mapping table cannot be both filtered for users and used as the lookup for the claims filter simultaneously.&lt;/P&gt;
&lt;FIGURE style="margin: 20px 0;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="diagram_2_nested.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31346i2F93D2E0DC4518CA/image-size/large?v=v2&amp;amp;px=999" role="button" title="diagram_2_nested.png" alt="diagram_2_nested.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;FIGCAPTION style="font-size: 13px; color: #6b8a97; margin-top: 8px; font-style: italic; text-align: center;"&gt;A filtered mapping table cannot be read inside the claims table's filter. The two protections cannot stack this way.&lt;/FIGCAPTION&gt;
&lt;/FIGURE&gt;
&lt;P&gt;Here is the error in the workspace. The mapping table already has a row filter, and the claims table's filter tries to read it.&lt;/P&gt;
&lt;FIGURE style="margin: 16px 0;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="shot_sql_error.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31347iE1115D2DA04AAA83/image-size/large?v=v2&amp;amp;px=999" role="button" title="shot_sql_error.png" alt="shot_sql_error.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;FIGCAPTION style="font-size: 13px; color: #6b8a97; margin-top: 8px; font-style: italic; text-align: center;"&gt;The bind fails because the filter reads a table that already has its own filter. The call sequence in the error names both tables.&lt;/FIGCAPTION&gt;
&lt;/FIGURE&gt;
&lt;H2 id="pattern" style="font-size: 26px; font-weight: bold; color: #1b3139; margin: 36px 0 16px; padding-bottom: 8px; border-bottom: 3px solid #FF3621; display: inline-block;"&gt;How to scope the fact table&lt;/H2&gt;
&lt;P&gt;A row filter is a SQL function, so it can hold a subquery. Bind the filter to the join key on the claims table, and resolve the agent inside the function by joining the mapping table to the directory on the current user. As long as the mapping table has no filter of its own, there is no nesting and it works for every agent.&lt;/P&gt;
&lt;PRE style="background: #1B3139; border-left: 4px solid #FF3621; border-radius: 6px; padding: 20px 24px; margin: 20px 0; font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace; font-size: 13.5px; line-height: 1.6; color: #e8ecf0; overflow-x: auto;"&gt;&lt;SPAN&gt;-- The filter takes the claims table's join key, the policy id.&lt;/SPAN&gt;
&lt;SPAN&gt;CREATE OR REPLACE FUNCTION&lt;/SPAN&gt; scope_claims_by_policy(policy_id &lt;SPAN&gt;STRING&lt;/SPAN&gt;)
  &lt;SPAN&gt;RETURN&lt;/SPAN&gt; is_account_group_member(&lt;SPAN&gt;'claims_supervisors'&lt;/SPAN&gt;)
      &lt;SPAN&gt;OR&lt;/SPAN&gt; policy_id &lt;SPAN&gt;IN&lt;/SPAN&gt; (
           &lt;SPAN&gt;SELECT&lt;/SPAN&gt; m.policy_id
           &lt;SPAN&gt;FROM&lt;/SPAN&gt;   policy_agent_map m
           &lt;SPAN&gt;JOIN&lt;/SPAN&gt;   agent_directory d &lt;SPAN&gt;ON&lt;/SPAN&gt; m.agent_id = d.agent_id
           &lt;SPAN&gt;WHERE&lt;/SPAN&gt;  d.user_email = current_user()
         );

&lt;SPAN&gt;ALTER TABLE&lt;/SPAN&gt; claim_fact &lt;SPAN&gt;SET ROW FILTER&lt;/SPAN&gt; scope_claims_by_policy &lt;SPAN&gt;ON&lt;/SPAN&gt; (policy_id);&lt;/PRE&gt;
&lt;FIGURE style="margin: 16px 0;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="shot_claimfact_filter.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31348iA1C9C95394ED28AA/image-size/large?v=v2&amp;amp;px=999" role="button" title="shot_claimfact_filter.png" alt="shot_claimfact_filter.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;FIGCAPTION style="font-size: 13px; color: #6b8a97; margin-top: 8px; font-style: italic; text-align: center;"&gt;The claims table in Catalog Explorer. The row filter is bound to &lt;CODE&gt;scope_by_policy&lt;/CODE&gt; on the &lt;CODE&gt;policy_id&lt;/CODE&gt; column.&lt;/FIGCAPTION&gt;
&lt;/FIGURE&gt;
&lt;FIGURE style="margin: 16px 0;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&amp;nbsp;&lt;/P&gt;
&lt;P&gt;&amp;nbsp;&lt;/P&gt;
&lt;P&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="shot_function.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31355i99B05D5C00A784AB/image-size/large?v=v2&amp;amp;px=999" role="button" title="shot_function.png" alt="shot_function.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;P&gt;&amp;nbsp;&lt;/P&gt;
&lt;FIGCAPTION style="font-size: 13px; color: #6b8a97; margin-top: 8px; font-style: italic; text-align: center;"&gt;The filter function as it sits in the catalog. Note that the security type is DEFINER, which is why the agent needs no access to the lookup.&lt;/FIGCAPTION&gt;
&lt;/FIGURE&gt;
&lt;P&gt;There is a useful detail in how this runs. Row filters run with the object owner's rights, apart from the identity checks &lt;CODE&gt;current_user()&lt;/CODE&gt; and &lt;CODE&gt;is_account_group_member()&lt;/CODE&gt;, which run as the querying user. So an agent needs no access to the mapping table or the directory. The function reads them under the owner's rights, and only the identity check runs as the agent. The agent needs access only to the claims table.&lt;/P&gt;
&lt;H2 id="control" style="font-size: 26px; font-weight: bold; color: #1b3139; margin: 36px 0 16px; padding-bottom: 8px; border-bottom: 3px solid #FF3621; display: inline-block;"&gt;One control table for many tables&lt;/H2&gt;
&lt;P&gt;In a real estate of tables, the same question comes up again and again on different tables. Rather than scatter lookups across many functions, put the mappings in one place. Create a small control table that holds the user and every key you scope on, and never apply a filter to it. Then point the filter for each table at that one control table.&lt;/P&gt;
&lt;PRE style="background: #1B3139; border-left: 4px solid #FF3621; border-radius: 6px; padding: 20px 24px; margin: 20px 0; font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace; font-size: 13.5px; line-height: 1.6; color: #e8ecf0; overflow-x: auto;"&gt;&lt;SPAN&gt;-- One control table. Keep it locked down and never filter it.&lt;/SPAN&gt;
&lt;SPAN&gt;CREATE TABLE&lt;/SPAN&gt; agent_entitlements (
  user_email &lt;SPAN&gt;STRING&lt;/SPAN&gt;,
  agent_id   &lt;SPAN&gt;STRING&lt;/SPAN&gt;,
  policy_id  &lt;SPAN&gt;STRING&lt;/SPAN&gt;
);
&lt;SPAN&gt;REVOKE ALL PRIVILEGES&lt;/SPAN&gt; &lt;SPAN&gt;ON TABLE&lt;/SPAN&gt; agent_entitlements &lt;SPAN&gt;FROM&lt;/SPAN&gt; &lt;SPAN&gt;`account users`&lt;/SPAN&gt;;

&lt;SPAN&gt;-- Filter for any table keyed by policy id.&lt;/SPAN&gt;
&lt;SPAN&gt;CREATE OR REPLACE FUNCTION&lt;/SPAN&gt; scope_by_policy(policy_id &lt;SPAN&gt;STRING&lt;/SPAN&gt;)
  &lt;SPAN&gt;RETURN&lt;/SPAN&gt; is_account_group_member(&lt;SPAN&gt;'claims_supervisors'&lt;/SPAN&gt;)
      &lt;SPAN&gt;OR&lt;/SPAN&gt; policy_id &lt;SPAN&gt;IN&lt;/SPAN&gt; (
           &lt;SPAN&gt;SELECT&lt;/SPAN&gt; policy_id &lt;SPAN&gt;FROM&lt;/SPAN&gt; agent_entitlements
           &lt;SPAN&gt;WHERE&lt;/SPAN&gt; user_email = current_user()
         );

&lt;SPAN&gt;-- Filter for any table keyed by agent id, the mapping table included.&lt;/SPAN&gt;
&lt;SPAN&gt;CREATE OR REPLACE FUNCTION&lt;/SPAN&gt; scope_by_agent(agent_id &lt;SPAN&gt;STRING&lt;/SPAN&gt;)
  &lt;SPAN&gt;RETURN&lt;/SPAN&gt; is_account_group_member(&lt;SPAN&gt;'claims_supervisors'&lt;/SPAN&gt;)
      &lt;SPAN&gt;OR&lt;/SPAN&gt; agent_id &lt;SPAN&gt;IN&lt;/SPAN&gt; (
           &lt;SPAN&gt;SELECT&lt;/SPAN&gt; agent_id &lt;SPAN&gt;FROM&lt;/SPAN&gt; agent_entitlements
           &lt;SPAN&gt;WHERE&lt;/SPAN&gt; user_email = current_user()
         );&lt;/PRE&gt;
&lt;FIGURE style="margin: 20px 0;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="diagram_3_control.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31351i18EAC47D82F6F931/image-size/large?v=v2&amp;amp;px=999" role="button" title="diagram_3_control.png" alt="diagram_3_control.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;/FIGURE&gt;
&lt;P&gt;Because the control table is a separate object that is never filtered, you can now apply a filter to any table safely, including the mapping table. Tag each key column with a governed tag and create one policy per key type in Catalog Explorer, and the rule covers every table that carries that key. You can also bind a function to a single table by hand with &lt;CODE&gt;SET ROW FILTER&lt;/CODE&gt;. Onboarding an agent or moving a book of policies becomes a change to rows in the control table, not a change to code.&lt;/P&gt;
&lt;H2 id="read-map" style="font-size: 26px; font-weight: bold; color: #1b3139; margin: 36px 0 16px; padding-bottom: 8px; border-bottom: 3px solid #FF3621; display: inline-block;"&gt;When users also need to read the mapping table&lt;/H2&gt;
&lt;P&gt;If agents only ever read the claims table, you are done. If they also need to read the mapping table directly, there are two clean choices.&lt;/P&gt;
&lt;UL style="padding-left: 24px; margin: 0 0 16px;"&gt;
&lt;LI style="margin-bottom: 8px;"&gt;&lt;STRONG&gt;A view.&lt;/STRONG&gt; Leave the mapping table unfiltered, and create a view over it that filters to the current user. Give agents access to the view, not the base table. This is the smaller change.&lt;/LI&gt;
&lt;LI style="margin-bottom: 8px;"&gt;&lt;STRONG&gt;The control table.&lt;/STRONG&gt; With the control table in place, apply a filter to the mapping table directly with the &lt;CODE&gt;scope_by_agent&lt;/CODE&gt; function above. The control table is separate and never filtered, so there is no nesting and no view to maintain.&lt;/LI&gt;
&lt;/UL&gt;
&lt;PRE style="background: #1B3139; border-left: 4px solid #FF3621; border-radius: 6px; padding: 20px 24px; margin: 20px 0; font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace; font-size: 13.5px; line-height: 1.6; color: #e8ecf0; overflow-x: auto;"&gt;&lt;SPAN&gt;-- The view option, if you would rather not filter the mapping table.&lt;/SPAN&gt;
&lt;SPAN&gt;CREATE OR REPLACE VIEW&lt;/SPAN&gt; policy_agent_map_user &lt;SPAN&gt;AS&lt;/SPAN&gt;
  &lt;SPAN&gt;SELECT&lt;/SPAN&gt; * &lt;SPAN&gt;FROM&lt;/SPAN&gt; policy_agent_map
  &lt;SPAN&gt;WHERE&lt;/SPAN&gt; is_account_group_member(&lt;SPAN&gt;'claims_supervisors'&lt;/SPAN&gt;)
     &lt;SPAN&gt;OR&lt;/SPAN&gt; agent_id &lt;SPAN&gt;IN&lt;/SPAN&gt; (
           &lt;SPAN&gt;SELECT&lt;/SPAN&gt; agent_id &lt;SPAN&gt;FROM&lt;/SPAN&gt; agent_entitlements
           &lt;SPAN&gt;WHERE&lt;/SPAN&gt; user_email = current_user()
         );&lt;/PRE&gt;
&lt;P&gt;Taking the control table option, the mapping table then carries its own row filter, and it resolves through the same control table as the claims table.&lt;/P&gt;
&lt;FIGURE style="margin: 16px 0;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="shot_map_filter.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31352i3760B000E60D73B9/image-size/large?v=v2&amp;amp;px=999" role="button" title="shot_map_filter.png" alt="shot_map_filter.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;FIGCAPTION style="font-size: 13px; color: #6b8a97; margin-top: 8px; font-style: italic; text-align: center;"&gt;The mapping table with its own row filter, &lt;CODE&gt;scope_by_agent&lt;/CODE&gt; on the &lt;CODE&gt;agent_id&lt;/CODE&gt; column, driven by the same control table.&lt;/FIGCAPTION&gt;
&lt;/FIGURE&gt;
&lt;H2 id="checking" style="font-size: 26px; font-weight: bold; color: #1b3139; margin: 36px 0 16px; padding-bottom: 8px; border-bottom: 3px solid #FF3621; display: inline-block;"&gt;Checking it works&lt;/H2&gt;
&lt;P&gt;These results are from a live run. The claims table holds 40 claims across 12 policies and 3 agents. Signed in as an agent who services 3 policies, a plain query with no where clause returns only their rows.&lt;/P&gt;
&lt;FIGURE style="margin: 16px 0;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="shot_sql_scoped.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31353i57823D656CFB7B80/image-size/large?v=v2&amp;amp;px=999" role="button" title="shot_sql_scoped.png" alt="shot_sql_scoped.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;FIGCAPTION style="font-size: 13px; color: #6b8a97; margin-top: 8px; font-style: italic; text-align: center;"&gt;Claims grouped by policy for the signed-in agent. Three policies returned, 11 of the 40 claims in the table.&lt;/FIGCAPTION&gt;
&lt;P&gt;&amp;nbsp;&lt;/P&gt;
&lt;/FIGURE&gt;
&lt;P&gt;The same agent reading the mapping table sees only their own three policies, not all twelve.&lt;/P&gt;
&lt;FIGURE style="margin: 16px 0;"&gt;
&lt;P style="color: #6b8a97; font-family: monospace; font-size: 13px; text-align: center; margin: 16px 0 0;"&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-center" image-alt="shot_sql_mapping.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31354iE9AB0CC8986803F0/image-size/large?v=v2&amp;amp;px=999" role="button" title="shot_sql_mapping.png" alt="shot_sql_mapping.png" /&gt;&lt;/span&gt;&lt;/P&gt;
&lt;P&gt;&amp;nbsp;&lt;/P&gt;
&lt;FIGCAPTION style="font-size: 13px; color: #6b8a97; margin-top: 8px; font-style: italic; text-align: center;"&gt;The mapping table read by the same agent. Three of its twelve rows.&lt;/FIGCAPTION&gt;
&lt;/FIGURE&gt;
&lt;TABLE style="border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 15px;"&gt;
&lt;TBODY&gt;
&lt;TR&gt;
&lt;TH style="background: #1B3139; color: #fff; padding: 10px 14px; text-align: left;"&gt;Query, run with no where clause&lt;/TH&gt;
&lt;TH style="background: #1B3139; color: #fff; padding: 10px 14px; text-align: left;"&gt;All rows in the table&lt;/TH&gt;
&lt;TH style="background: #1B3139; color: #fff; padding: 10px 14px; text-align: left;"&gt;Seen as one agent&lt;/TH&gt;
&lt;/TR&gt;
&lt;TR&gt;
&lt;TD style="padding: 10px 14px; border-bottom: 1px solid #E0E0E0;"&gt;claims&lt;/TD&gt;
&lt;TD style="padding: 10px 14px; border-bottom: 1px solid #E0E0E0;"&gt;40&lt;/TD&gt;
&lt;TD style="padding: 10px 14px; border-bottom: 1px solid #E0E0E0;"&gt;11&lt;/TD&gt;
&lt;/TR&gt;
&lt;TR style="background: #F7F9FA;"&gt;
&lt;TD style="padding: 10px 14px; border-bottom: 1px solid #E0E0E0;"&gt;distinct policies&lt;/TD&gt;
&lt;TD style="padding: 10px 14px; border-bottom: 1px solid #E0E0E0;"&gt;12&lt;/TD&gt;
&lt;TD style="padding: 10px 14px; border-bottom: 1px solid #E0E0E0;"&gt;3&lt;/TD&gt;
&lt;/TR&gt;
&lt;TR&gt;
&lt;TD style="padding: 10px 14px; border-bottom: 1px solid #E0E0E0;"&gt;mapping rows&lt;/TD&gt;
&lt;TD style="padding: 10px 14px; border-bottom: 1px solid #E0E0E0;"&gt;12&lt;/TD&gt;
&lt;TD style="padding: 10px 14px; border-bottom: 1px solid #E0E0E0;"&gt;3&lt;/TD&gt;
&lt;/TR&gt;
&lt;/TBODY&gt;
&lt;/TABLE&gt;
&lt;P&gt;Test the all-access path too. A member of the &lt;CODE&gt;claims_supervisors&lt;/CODE&gt; group, or a service account that runs the pipelines, gets every row. To check that path without waiting on group membership to take effect, add one row to the control table for your own user and rerun. The counts move at once. Remove the row and they return to the scoped numbers.&lt;/P&gt;
&lt;H2 id="keep-in-mind" style="font-size: 26px; font-weight: bold; color: #1b3139; margin: 36px 0 16px; padding-bottom: 8px; border-bottom: 3px solid #FF3621; display: inline-block;"&gt;Things to keep in mind&lt;/H2&gt;
&lt;UL style="padding-left: 24px; margin: 0 0 16px;"&gt;
&lt;LI style="margin-bottom: 8px;"&gt;The tables a filter reads must not have their own active filter or mask. Keep the control table and the directory unfiltered.&lt;/LI&gt;
&lt;LI style="margin-bottom: 8px;"&gt;Keep the control table small so the join runs as a broadcast hash join. A few simple conditions read faster than many.&lt;/LI&gt;
&lt;LI style="margin-bottom: 8px;"&gt;Match the function parameter type to the key column type, and keep ANSI mode on, so a bad cast raises an error rather than returning null and letting rows through.&lt;/LI&gt;
&lt;LI style="margin-bottom: 8px;"&gt;A table can have one row filter in effect. A policy keyed on policy id and a policy keyed on agent id are separate and do not clash on the same table.&lt;/LI&gt;
&lt;LI style="margin-bottom: 8px;"&gt;Use the key that matches the table's grain. Use the policy id for a policy-level table, or a household or account key for those grains, and tag whichever key you join on.&lt;/LI&gt;
&lt;/UL&gt;
&lt;HR /&gt;
&lt;P&gt;The shape of this pattern is the same whenever the value that decides access is not on the table you want to protect. Put the mapping in a control table, keep it unfiltered, and read it from a row filter bound to the join key. It scopes one table or a whole estate with the same small set of pieces.&lt;/P&gt;
&lt;P style="font-size: 15px; color: #5a7a86; margin-top: 24px;"&gt;Further reading in the Databricks documentation: &lt;A style="color: #ff3621; text-decoration: none;" href="https://docs.databricks.com/aws/en/tables/row-and-column-filters" target="_blank"&gt;Filter sensitive table data using row filters and column masks&lt;/A&gt;, &lt;A style="color: #ff3621; text-decoration: none;" href="https://docs.databricks.com/aws/en/data-governance/unity-catalog/abac/" target="_blank"&gt;Attribute-based access control in Unity Catalog&lt;/A&gt;, and &lt;A style="color: #ff3621; text-decoration: none;" href="https://docs.databricks.com/aws/en/views/dynamic" target="_blank"&gt;Create a dynamic view&lt;/A&gt;.&lt;/P&gt;
&lt;P&gt;I would like to hear from others, too. Have you had cases where the value that decides access sits in an awkward place, a different table, a hierarchy, or several hops away, and how did you handle it in your own projects? Share what worked for you in the comments.&lt;/P&gt;
&lt;/DIV&gt;</description>
      <pubDate>Mon, 21 Sep 2026 13:39:31 GMT</pubDate>
      <guid>https://community.databricks.com/t5/community-articles/row-level-security-when-the-filter-column-is-not-on-your-table/m-p/169339#M1587</guid>
      <dc:creator>Ashwin_DSA</dc:creator>
      <dc:date>2026-09-21T13:39:31Z</dc:date>
    </item>
    <item>
      <title>Re: Row-level security when the filter column is not on your table</title>
      <link>https://community.databricks.com/t5/community-articles/row-level-security-when-the-filter-column-is-not-on-your-table/m-p/169542#M1592</link>
      <description>&lt;P&gt;Thanks for sharing this &lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/216690"&gt;@Ashwin_DSA&lt;/a&gt;&amp;nbsp;. I'd be interested in your thoughts on scaling this pattern when the entitlement table has millions of mappings, but only a few per user. Could filtering by the current user keep the lookup small enough to broadcast, or would you suggest a different table layout in that case?&lt;/P&gt;</description>
      <pubDate>Wed, 23 Sep 2026 08:08:13 GMT</pubDate>
      <guid>https://community.databricks.com/t5/community-articles/row-level-security-when-the-filter-column-is-not-on-your-table/m-p/169542#M1592</guid>
      <dc:creator>ivanvyd</dc:creator>
      <dc:date>2026-09-23T08:08:13Z</dc:date>
    </item>
    <item>
      <title>Re: Row-level security when the filter column is not on your table</title>
      <link>https://community.databricks.com/t5/community-articles/row-level-security-when-the-filter-column-is-not-on-your-table/m-p/169545#M1593</link>
      <description>&lt;P&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/216690"&gt;@Ashwin_DSA&lt;/a&gt;&amp;nbsp;&lt;/P&gt;&lt;P&gt;Good to see this post here as we landed on the same pattern a few months back after testing a few approaches and looping in the Databricks team to sense-check whether we were missing anything.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Here's what we observed:&lt;/STRONG&gt;&lt;/P&gt;&lt;UL&gt;&lt;LI&gt;&lt;STRONG&gt;Started with a direct LEFT SEMI JOIN&lt;/STRONG&gt; on the control table, got SortMergeJoin with two full shuffle exchanges across 200 partitions on both sides. &lt;STRONG&gt;Contributing factor:&lt;/STRONG&gt; missing table statistics, the optimizer had no idea the per-user result was small.&amp;nbsp;&lt;/LI&gt;&lt;/UL&gt;&lt;LI-CODE lang="markup"&gt;FROM wfm_site s
LEFT SEMI JOIN wfm_user_site_mapping m
    ON  s.site_id    = m.site_id
    AND m.user_email = lower(current_user())
-- Physical plan: SortMergeJoin
-- Exchange hashpartitioning(site_id, 200)  ← shuffle
​&lt;/LI-CODE&gt;&lt;UL&gt;&lt;LI&gt;&lt;STRONG&gt;Switched to a SQL UDF with EXISTS&lt;/STRONG&gt;: the plan moved to BroadcastHashJoin LeftSemi. Since SQL UDFs are inlined at planning time, Photon treats it as a native join with no ColumnarToRow transition, unlike a Python UDF which would have shown BatchEvalPython and fallen back on every row.&lt;/LI&gt;&lt;/UL&gt;&lt;LI-CODE lang="markup"&gt;CREATE OR REPLACE FUNCTION fx_row_filter_wfm_site(site_id LONG)
RETURNS BOOLEAN
RETURN EXISTS (
    SELECT /*+ BROADCAST(m) */ 1
    FROM   wfm_user_site_mapping m
    WHERE  m.user_email = lower(current_user())
      AND  m.site_id    = fx_row_filter_wfm_site.site_id
)&lt;/LI-CODE&gt;&lt;UL&gt;&lt;LI&gt;The broadcast is &lt;STRONG&gt;EXECUTOR_BROADCAST&lt;/STRONG&gt; driven by AQE, not hint-driven. The user_email = current_user() predicate pushes all the way to the parquet scan, so the per-user rows are already small before the exchange happens.&amp;nbsp;&amp;nbsp;&lt;/LI&gt;&lt;/UL&gt;&lt;LI-CODE lang="markup"&gt;Confirmed in physical plan: 
Scan parquet wfm_user_site_mapping 
PushedFilters: [EqualTo(user_email, &amp;lt;current_user&amp;gt;), IsNotNull(site_id)]&lt;/LI-CODE&gt;&lt;UL&gt;&lt;LI&gt;Ran ANALYZE TABLE .. COMPUTE STATISTICS FOR ALL COLUMNS after each mapping table rebuild, this made the broadcast strategy consistent across both the UDF and direct JOIN approaches.&amp;nbsp;&lt;/LI&gt;&lt;/UL&gt;&lt;LI-CODE lang="markup"&gt;ANALYZE TABLE wfm_user_site_mapping
COMPUTE STATISTICS FOR ALL COLUMNS&lt;/LI-CODE&gt;&lt;P&gt;&lt;U&gt;&lt;STRONG&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/249473"&gt;@ivanvyd&lt;/a&gt;&amp;nbsp;&lt;/STRONG&gt;&lt;/U&gt;&lt;/P&gt;&lt;P&gt;&lt;U&gt;&lt;STRONG&gt;On the scaling front:&lt;/STRONG&gt;&lt;/U&gt;&lt;/P&gt;&lt;P&gt;The per-user predicate pushdown is what keeps the broadcast small regardless of overall table size. We confirmed it in the physical plan, PushedFilters: [EqualTo(user_email, &amp;lt;current_user&amp;gt;)] appears at the scan node, not after. We also split into separate lean tables per dimension (one for site, one for contract) to avoid cross-join bloat from contract-to-site relationships inflating the broadcast payload.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;&lt;EM&gt;One thing worth flagging:&lt;/EM&gt;&lt;/STRONG&gt; a unified two-param nullable function (site_id IS NULL OR ..) was noticeably slower than a single-param function per dimension. The &lt;STRONG&gt;IS NULL OR&lt;/STRONG&gt; pattern blocks predicate pushdown, Spark could't push a partial OR condition as a simple equality filter to the parquet scan, so it falls back to filtering in memory after the scan. Plain equality predicates pushed cleanly.&lt;/P&gt;&lt;LI-CODE lang="markup"&gt;-- IS NULL OR blocks predicate pushdown
CREATE FUNCTION fx_row_filter_wfm(site_id LONG, contract_id LONG)
RETURNS BOOLEAN
RETURN EXISTS (
    SELECT 1 FROM wfm_user_permission_mapping m
    WHERE  m.user_email = lower(current_user())
      AND (site_id     IS NULL OR m.site_id     = fx_row_filter_wfm.site_id)
      AND (contract_id IS NULL OR m.contract_id = fx_row_filter_wfm.contract_id)
    -- Spark couldn't push a partial OR condition to parquet scan
);

-- Plain equality → full predicate pushdown to scan
CREATE FUNCTION fx_row_filter_wfm_site(site_id LONG)
RETURNS BOOLEAN
RETURN EXISTS (
    SELECT 1 FROM wfm_user_site_mapping m
    WHERE  m.user_email = lower(current_user())
      AND  m.site_id    = fx_row_filter_wfm_site.site_id
    -- PushedFilters: [EqualTo(user_email,...), IsNotNull(site_id)]
);&lt;/LI-CODE&gt;&lt;P&gt;&lt;STRONG&gt;On scale:&amp;nbsp;&lt;/STRONG&gt;we also tested this on much larger tables (~2B and ~18B rows) with the same row filter enforced. The overhead scales with how much data the user is permitted to see, not the total table size. Users with narrow access actually outperformed broader-access users on larger tables, fewer permitted keys means fewer matching files, so file pruning kicks in more aggressively at the scan.&amp;nbsp; A narrow-access restricted user on an 18B row table came in faster than a broad-access user on a 2B row table across repeated runs.&lt;/P&gt;</description>
      <pubDate>Wed, 23 Sep 2026 09:18:19 GMT</pubDate>
      <guid>https://community.databricks.com/t5/community-articles/row-level-security-when-the-filter-column-is-not-on-your-table/m-p/169545#M1593</guid>
      <dc:creator>data_pulse</dc:creator>
      <dc:date>2026-09-23T09:18:19Z</dc:date>
    </item>
    <item>
      <title>Re: Row-level security when the filter column is not on your table</title>
      <link>https://community.databricks.com/t5/community-articles/row-level-security-when-the-filter-column-is-not-on-your-table/m-p/169658#M1595</link>
      <description>&lt;P&gt;Hi&amp;nbsp;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/249473"&gt;@ivanvyd&lt;/a&gt;,&lt;/P&gt;
&lt;P&gt;Thanks for the question. The short version is that filtering by current_user() usually keeps the broadcast small, but the thing to factor in the design is the lookup in the entitlement table, not the broadcast itself.&amp;nbsp;current_user() resolves to a single constant for the query, so the subquery runs once, not per row. It returns only that user's handful of keys, and that small result is broadcast to filter the fact table. So even with millions of mappings, the broadcast side stays tiny as long as each user maps to only a few keys.&lt;/P&gt;
&lt;P&gt;The expensive part is reading the millions-row entitlement table to find those few rows on every query. That is where I think the layout matters. Cluster the entitlement table on user_email, liquid clustering is the easy choice, so Delta can skip to the user's files instead of scanning the whole table. Keep it compacted with OPTIMIZE, avoid a pile of small files, and make sure you are not wrapping user_email in a function or cast that defeats data skipping. With that in place, the per-query lookup prunes to a few files, and the pattern should hold at scale.&lt;/P&gt;
&lt;P&gt;If you hit a case where per-user sets are legitimately large, that is the point to rethink the layout, for example, scoping by a group or an attribute rather than an explicit per-row mapping. But for a few mappings per user, a well-clustered control table is the right call and should scale fine.&lt;/P&gt;
&lt;P&gt;&amp;nbsp;&lt;/P&gt;</description>
      <pubDate>Thu, 24 Sep 2026 09:53:13 GMT</pubDate>
      <guid>https://community.databricks.com/t5/community-articles/row-level-security-when-the-filter-column-is-not-on-your-table/m-p/169658#M1595</guid>
      <dc:creator>Ashwin_DSA</dc:creator>
      <dc:date>2026-09-24T09:53:13Z</dc:date>
    </item>
  </channel>
</rss>

