The traditional approach to row-level data isolation is maintaining separate views per segment:
CREATE VIEW sales_emea AS SELECT * FROM sales WHERE region = 'EMEA';CREATE VIEW sales_apac AS SELECT * FROM sales WHERE region = 'APAC';CREATE VIEW sales_americas AS SELECT * FROM sales WHERE region = 'AMERICAS';This creates a maintenance problem: adding a region requires a new view, adding a column requires updating every view, and BI tools hardcoded to a specific view name require coordination to refactor.
BigQuery Row Access Policies replace this with dynamic filtering based on the querying user’s identity — no view management required.
Creating Row Access Policies
CREATE ROW ACCESS POLICY region_filter_emeaON project.dataset.salesGRANT TO ("group:sales-emea@yourdomain.com")FILTER USING (region = 'EMEA');
CREATE ROW ACCESS POLICY region_filter_apacON project.dataset.salesGRANT TO ("group:sales-apac@yourdomain.com")FILTER USING (region = 'APAC');When a user in the sales-emea group queries the sales table directly, they see only EMEA rows. When a user in sales-apac queries the same table, they see only APAC rows. Both users query the same table with the same SQL — the filter is invisible to them.
For users who should see everything (admins, reporting pipelines):
CREATE ROW ACCESS POLICY full_accessON project.dataset.salesGRANT TO ("group:data-engineers@yourdomain.com")FILTER USING (TRUE);FILTER USING (TRUE) is the pattern for unrestricted access within a row access policy framework. It’s explicit about intent: this group is granted access to all rows, and that decision is documented in the policy rather than implied by the absence of a filter.
Dynamic Filtering with SESSION_USER()
Static filters (region = constant) work for clear geographic or organizational segmentation. But sometimes the filter should depend on who specifically is running the query, not just which group they belong to.
SESSION_USER() returns the email address of the principal running the query:
CREATE ROW ACCESS POLICY manager_sees_their_teamON project.dataset.employee_metricsGRANT TO ("group:managers@yourdomain.com")FILTER USING ( manager_email = SESSION_USER() OR SESSION_USER() IN (SELECT email FROM project.dataset.hr_admins));Managers see metrics for employees where manager_email matches their own email. HR admins — maintained in a separate lookup table — see everyone. The filter references live data; when someone is added to the hr_admins table, they immediately gain full access. No policy update required.
This pattern works well when you have a natural ownership or hierarchy relationship in your data and you want to enforce “you can only see your records” without managing individual user grants.
Important Behaviors to Know
Policies are OR’d for multi-group membership. If a user belongs to both sales-emea and sales-apac groups, they see both EMEA and APAC rows. The filters combine with OR logic. Plan your group structure accordingly.
Policies don’t stack with missing policies. If any row access policy exists on a table, users without a matching policy see zero rows — not all rows. This is the right security default (deny unless explicitly permitted), but it catches teams off guard when they add the first policy and existing users suddenly see nothing.
CREATE OR REPLACE updates atomically. When updating a policy, use CREATE OR REPLACE ROW ACCESS POLICY to avoid a window where the policy doesn’t exist:
CREATE OR REPLACE ROW ACCESS POLICY region_filter_emeaON project.dataset.salesGRANT TO ("group:sales-emea@yourdomain.com")FILTER USING (region = 'EMEA');Policies disappear when tables are recreated. If dbt drops and recreates the table, all row access policies are deleted with it. This is why Secured Table Materialization in dbt exists — it reapplies policies after each rebuild.
Row and Column Policies Combine
Row access policies and column-level policy tags operate independently and compose naturally. A user in sales-emea might see only EMEA rows (row policy) and not see the margin column (policy tag). Both restrictions apply without either knowing about the other.
BigQuery Dynamic Data Masking adds a third layer: the user can see the column, but the values are obscured. A sales analyst might see all their region’s rows, see that there’s an email column, but see hashed email values rather than actual addresses.
Design the layers separately. Row policies answer “which records?” Column tags answer “which fields?” Masking answers “how much of the field?” Each layer can be adjusted independently as access requirements change.
Managing Policy Sprawl
Add policies incrementally, starting with the highest-sensitivity data and clearest segmentation requirements. Policies add query overhead and governance complexity; apply them only to tables that require row-level isolation.
A practical threshold: replace existing per-segment views with row access policies. If data is accessed through a single shared view or directly, row access policies are appropriate only when a specific access control requirement drives the need.
Review policies quarterly alongside IAM permissions. Policies on decommissioned tables persist until explicitly dropped. DROP ALL ROW ACCESS POLICIES ON project.dataset.table cleans everything at once when retiring a table.