©Code byMilanParmar

Fix “infinite recursion detected in policy for relation”

What the error means, the three patterns that cause it in Lovable and Supabase apps, how to find the policy that loops, the fix with SQL for each pattern, how to test it as a real user, and the shortcuts that leave your data open.

9 min read

"Infinite recursion detected in policy for relation" (Postgres error 42P17) means a row level security policy reads a table whose own policy runs the first one again, so Postgres stops the loop. The fix is to move that lookup into a security definer function in a private schema, and call the function from the policy.

In a Lovable, Bolt, or other Supabase app, the error usually appears right after a prompt like "let admins see all users" or "let team members see each other". Screens that worked go empty, or the app shows an error with the code 42P17. Below is why it happens, the three patterns behind it, how to find the policy that loops, the SQL to fix each one, and how to check the fix didn't open your data to everyone. If you haven't checked the rest of your policies yet, the Supabase RLS checklist for Lovable apps covers them. Run the SQL in the Supabase SQL editor. On Lovable Cloud, the SQL editor is under More, then Cloud, and the list of policies under Cloud, then Database, then RLS policies.

What infinite recursion in a policy means

Row level security (RLS) attaches a rule, called a policy, to a table. Every time a user reads the table, Postgres checks each row against the rule. If the rule itself reads a table, the policies on that table run too.

The loop starts when a policy on profilesasks "is this user an admin?" by reading profiles. To answer, Postgres applies the policy on profiles, which asks the same question, which reads profilesagain. Supabase's troubleshooting guide describes it as a circular dependency between policies: a policy queries a table with RLS on, and that table's policies end up triggering the original policy again. The chain can be direct, one table reading itself, or run through two or more tables.

Postgres doesn't try to resolve it. It fails the whole query, for every user the policy applies to. That's why one new policy can blank out every screen that reads the table, and why an app that loads the user's profile at sign-in can sit on a spinner; other causes of that are in Lovable app hangs after login.

Three policies that cause infinite recursion

1. An admin check on the profiles table

A common one in AI-built apps. Roles live in a role column on profiles, and a policy on profiles checks that column:

SQL: a policy that loops
create policy "Admins read all profiles"
on public.profiles for select
to authenticated
using (
  exists (
    select 1 from public.profiles
    where id = auth.uid() and role = 'admin'
  )
);

2. A members table that checks membership in itself

For teams or workspaces: "members can see the other members of their team". The policy on team_members reads team_membersto find the user's teams:

SQL: another policy that loops
create policy "Members see their team"
on public.team_members for select
to authenticated
using (
  team_id in (
    select team_id from public.team_members
    where user_id = auth.uid()
  )
);

3. Two tables whose policies read each other

Supabase's RLS guide uses this example: a policy on lists checks list_membersto see who's a member, and a policy on list_members checks lists to see who owns the list. Each one is reasonable on its own. Together they form a loop, and the table named in the error may not be the one whose policy you need to change.

Find the policy that causes the recursion

The error tells you the table, not the policy. Start by listing every policy that mentions its own table:

SQL: policies that read their own table
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
  and (qual ilike '%' || tablename || '%'
    or with_check ilike '%' || tablename || '%');

This can show a few false matches, such as a column named after the table. Read the qualcolumn, which holds the policy's condition, and look for a select … fromthe same table. If nothing matches, the loop runs through two tables. List every policy that looks up another table, and draw the arrows: table A's policy reads B, B's reads A.

SQL: policies that look up other tables
select tablename, policyname, cmd, qual
from pg_policies
where schemaname = 'public'
  and (qual ilike '%select%' or with_check ilike '%select%')
order by tablename;

The error can also appear when creating a record, not just reading one: an insert that returns the new row runs the select policy too. Storage counts as well. A policy on storage.objects that checks a table with this loop makes uploads fail.

Fix infinite recursion with a security definer function

A security definer function runs with the rights of the role that created it, not the user calling it. On Supabase, functions you create in the SQL editor are owned by postgres, which bypasses RLS, so the lookup inside the function doesn't trigger the policy again. The loop is broken without loosening anything.

Supabase's guide adds three rules, and each one matters. Put the function in a schema that isn't exposed to the API, such as private, because a security definer function in an exposed schema can be called by anyone with its creator's rights. Set search_path = ''and write every table name in full. And only let logged-in users run it. Here's the fix for the admin pattern:

SQL: the fix for an admin check
create schema if not exists private;

create or replace function private.is_admin()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1 from public.profiles
    where id = (select auth.uid()) and role = 'admin'
  );
$$;

revoke execute on function private.is_admin() from public;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;

-- Swap the policy in one transaction, so there's no gap
begin;
drop policy if exists "Admins read all profiles" on public.profiles;
create policy "Admins read all profiles"
on public.profiles for select
to authenticated
using ((select private.is_admin()));
commit;

Wrapping the call in selectlets Postgres run it once per query rather than once per row, as Supabase's docs explain. Keep the separate policy that lets each user read their own row, so non-admins still see their profile.

Does the function need to be plpgsql?

No. Some posts claim a language sqlfunction gets inlined into the policy and still loops, so you need plpgsql. Postgres doesn't inline security definer functions, and Supabase's own docs use language sql for this fix. If it still loops, check the owner and that no other policy reads the table directly.

Check who can change the role column

While you're here: if users may update their own profile, can they set role to admin? An update policy of id = auth.uid() lets them. The safest fix is to move roles into their own table, such as user_roles, with no insert or update policies, so only your server code, using the secret key, can change them. Point private.is_admin() at that table.

The fix for team and membership tables

For teams, return the list of the user's team IDs from the function, and use it in every policy that needs it, including the policy on the members table itself. This is the same approach Supabase's guide uses for its lists example.

SQL: the fix for team membership
create or replace function private.user_team_ids()
returns setof uuid
language sql
stable
security definer
set search_path = ''
as $$
  select team_id from public.team_members
  where user_id = (select auth.uid());
$$;

revoke execute on function private.user_team_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.user_team_ids() to authenticated;

drop policy if exists "Members see their team" on public.team_members;

create policy "Members see their team"
on public.team_members for select
to authenticated
using (team_id in (select private.user_team_ids()));

create policy "Members see team projects"
on public.projects for select
to authenticated
using (team_id in (select private.user_team_ids()));

create index on public.team_members (user_id);

The index matters on a busy app. If team_members has a primary key of (team_id, user_id), Supabase's guide notes that it only indexes the first column, so lookups by user need their own. For the two-table pattern, use one function like this and point both tables' policies at it, so neither policy reads the other table directly.

When the fix doesn't work

The function only works if its owner bypasses RLS. If it's owned by another role, the loop can come back, or the function can quietly return nothing, so admins suddenly see only their own row. Check that the owner is postgres, which it is when you run the SQL in the editor, and that the table doesn't use force row level security, which applies policies even to the owner. And make sure the function is marked security definer: without it, the lookup runs as the user and the loop simply continues.

SQL: check the function's owner
select proname, proowner::regrole as owner
from pg_proc
where proname in ('is_admin', 'user_team_ids');

Test the fix as a logged-in user

Don't test in the SQL editor as it is. It runs as postgres, which skips RLS, so everything works there whether the policy is right or not. Switch to the role your app uses, as a specific user, inside a transaction you throw away:

SQL: run a query as a real user
begin;
set local role authenticated;
select set_config(
  'request.jwt.claims',
  '{"sub": "PASTE-A-USER-ID", "role": "authenticated"}',
  true
);

select * from public.team_members;  -- only this user's teams
select * from public.projects;      -- only their teams' projects

rollback;

Run it once for a member and once for a user in a different team. The second should see none of the first team's rows. Then do the same in the app with two real accounts, as described in the second-user test in the Supabase RLS checklist.

Store roles in the JWT instead: the alternative

The other way out is to stop reading a table for roles at all. Supabase supports a Custom Access Token Auth Hook, which runs before a login token is issued and can add a claim such as the user's role. Policies then read the claim with auth.jwt(), and no table is involved, so there's nothing to loop.

The catch, which Supabase's guide points out, is that a token isn't always up to date. Remove someone from a team or demote an admin, and they keep the old claim until their token refreshes. For roles that rarely change, that's acceptable. For team membership that changes daily, the function approach is safer.

Fixes that stop the error and open your data

These all make the error go away, which is why they get suggested, sometimes by the AI tool itself. Each one trades the error for a leak:

  • Turning off RLS on the table.Anyone with your public key can read it, and that key is in every visitor's browser.
  • Replacing the condition with using (true). Every logged-in user, or every visitor, sees every row.
  • Putting the function in the public schema.It becomes callable through the API with its creator's rights.
  • Reading the role from user_metadata.No table, no loop, but Supabase's docs warn users can edit their own user_metadata, so anyone can make themselves an admin.

When the recursion is a sign of a bigger problem

One looping policy is a quick fix. It's a sign of something bigger when the app has several roles and teams, and the policies were added one prompt at a time: there are usually other gaps alongside the loop, such as a table that never got RLS, or an update policy with no with check. Separating customers from each other is the part of an app where a mistake costs the most. On The Chat Company, every lookup is filtered by business so one customer's content can never appear in another's chatbot.

If your app has teams, payments, or personal data, my Launch-Ready Review checks every policy this way, as an outsider and as a second user, for a fixed $900–1,500. What fixing the findings costs is in the cost to fix a vibe-coded app. Or send me the error and your app's link.

Sources

  1. Row Level Security: security definer functions and recursion, Supabase Docs
  2. RLS policy causes infinite recursion, Supabase Docs
  3. Custom Claims and Role-based Access Control (RBAC), Supabase Docs
Milan Parmar

The developer

Freelance full-stack developer in Bengaluru with 5+ years of experience. I redesign websites, take over broken apps, and build SaaS products, with 18 case studies to show for it.

FAQs

Can I just turn off RLS on the table to stop the error?

It stops the error and opens the table. With RLS off, anyone holding your app's public key, which is in every visitor's browser, can read and usually change every row. A policy of using (true) has the same effect. Both look like fixes because the app starts working again. Keep RLS on and move the lookup into a security definer function instead.

Why does my query work in the SQL editor but fail in the app?

The Supabase SQL editor runs as the postgres role, which bypasses row level security, so the looping policy never runs there. Your app runs as anon or authenticated, so the policy runs and the loop starts. Always test policies as the role the app uses, with the set local role steps shown in this post, inside a transaction you roll back.

Can I ask Lovable to fix the infinite recursion error?

You can, and it may work, but check what it changed. Ask it to move the check into a security definer function in a private schema, with search_path set to empty. Then confirm three things yourself: RLS is still on, no policy now says using (true), and a second test user still can't see the first user's rows.