Stored procedures in Supabase are PostgreSQL functions written in PL/pgSQL or SQL. You create them in the SQL Editor using CREATE OR REPLACE FUNCTION, then call them from your frontend using supabase.rpc(). They are ideal for complex queries, atomic multi-table operations, and business logic that should run inside the database rather than on the client. Choose security invoker for user-context execution or security definer for elevated privileges.
Writing and Calling Stored Procedures in Supabase
Supabase exposes PostgreSQL functions through its auto-generated REST API, so any function you create in SQL becomes callable from your frontend via supabase.rpc(). This tutorial covers creating functions in PL/pgSQL and SQL, passing parameters, returning data, understanding security modes, and deciding when a database function is the right choice over an Edge Function.
Prerequisites
- A Supabase project with at least one table
- Access to the SQL Editor in the Supabase Dashboard
- Basic understanding of SQL and PostgreSQL
- @supabase/supabase-js installed in your frontend project
Step-by-step guide
Create a simple SQL function in the SQL Editor
Create a simple SQL function in the SQL Editor
Open the SQL Editor in your Supabase Dashboard (left sidebar > SQL Editor). Write a simple function that returns a value. SQL-language functions are the simplest type — they contain a single SQL statement. Click Run to create the function. Once created, it is automatically available through the REST API and can be called with supabase.rpc().
1-- Simple function that returns a greeting2create or replace function hello_world()3returns text4language sql5as $$6 select 'Hello from Supabase!';7$$;Expected result: The function is created and visible in Database > Functions in the Dashboard.
Create a PL/pgSQL function with parameters and logic
Create a PL/pgSQL function with parameters and logic
PL/pgSQL functions support variables, conditionals, loops, and transaction control. They are the standard choice for business logic that involves multiple steps. This example creates a function that inserts a record and returns the new ID. The DECLARE block defines variables, and the BEGIN/END block contains the logic.
1create or replace function create_todo(task_name text, user_uuid uuid)2returns bigint3language plpgsql4as $$5declare6 new_id bigint;7begin8 insert into todos (task, is_complete, user_id)9 values (task_name, false, user_uuid)10 returning id into new_id;1112 return new_id;13end;14$$;Expected result: The function is created and accepts task_name and user_uuid parameters, returning the new record ID.
Call the function from your frontend with supabase.rpc()
Call the function from your frontend with supabase.rpc()
Use the supabase.rpc() method to call any database function from your JavaScript or TypeScript code. Pass the function name as the first argument and parameters as an object. The parameter names must match the function's parameter names exactly. The response follows the same { data, error } pattern as other Supabase queries.
1import { createClient } from '@supabase/supabase-js'23const supabase = createClient(4 'https://your-project.supabase.co',5 'your-anon-key'6)78// Call a simple function9const { data: greeting } = await supabase.rpc('hello_world')10console.log(greeting) // 'Hello from Supabase!'1112// Call a function with parameters13const { data: newId, error } = await supabase.rpc('create_todo', {14 task_name: 'Buy groceries',15 user_uuid: 'a1b2c3d4-...'16})1718if (error) {19 console.error('Error:', error.message)20} else {21 console.log('Created todo with ID:', newId)22}Expected result: The frontend successfully calls the database function and receives the return value.
Return a table result set from a function
Return a table result set from a function
Functions can return entire result sets using RETURNS TABLE or RETURNS SETOF. This is useful for complex queries that combine multiple tables, apply business logic filters, or calculate aggregates. The result is returned as an array of objects, just like a regular Supabase query.
1-- Function that returns a filtered result set2create or replace function get_user_stats(target_user_id uuid)3returns table (4 total_todos bigint,5 completed_todos bigint,6 completion_rate numeric7)8language plpgsql9as $$10begin11 return query12 select13 count(*)::bigint as total_todos,14 count(*) filter (where is_complete = true)::bigint as completed_todos,15 case16 when count(*) > 017 then round((count(*) filter (where is_complete = true)::numeric / count(*)::numeric) * 100, 1)18 else 019 end as completion_rate20 from todos21 where user_id = target_user_id;22end;23$$;2425-- Call from JavaScript26-- const { data } = await supabase.rpc('get_user_stats', { target_user_id: userId })Expected result: The function returns a result set with total_todos, completed_todos, and completion_rate columns.
Understand security invoker vs security definer
Understand security invoker vs security definer
By default, functions execute as security invoker — they run with the permissions of the calling user, and RLS policies apply normally. If you need a function to perform operations the user cannot do directly (like inserting into an admin-only table), use security definer. Security definer functions run with the permissions of the function creator (usually the postgres superuser), bypassing RLS. Always set search_path = '' on security definer functions to prevent search path attacks.
1-- SECURITY INVOKER (default, recommended)2-- RLS policies apply, runs as the calling user3create or replace function get_my_profile()4returns json5language plpgsql6security invoker7as $$8begin9 return (10 select row_to_json(p)11 from profiles p12 where p.id = auth.uid()13 );14end;15$$;1617-- SECURITY DEFINER (elevated privileges)18-- Bypasses RLS, runs as function owner19-- MUST set search_path = '' for security20create or replace function admin_create_user_profile(21 target_user_id uuid,22 display_name text23)24returns void25language plpgsql26security definer27set search_path = ''28as $$29begin30 insert into public.profiles (id, name)31 values (target_user_id, display_name);32end;33$$;3435-- Restrict who can call the definer function36revoke execute on function admin_create_user_profile from public, anon;37grant execute on function admin_create_user_profile to authenticated;Expected result: Security invoker functions respect RLS; security definer functions bypass it with proper safeguards.
Complete working example
1-- Complete example: Stored procedures for a todo app2-- Run this in the Supabase SQL Editor34-- 1. Simple query function (security invoker = respects RLS)5create or replace function get_my_todos()6returns setof todos7language sql8security invoker9as $$10 select * from todos11 where user_id = auth.uid()12 order by created_at desc;13$$;1415-- 2. Insert function with validation16create or replace function create_todo(task_name text)17returns bigint18language plpgsql19security invoker20as $$21declare22 new_id bigint;23begin24 if trim(task_name) = '' then25 raise exception 'Task name cannot be empty';26 end if;2728 insert into todos (task, is_complete, user_id)29 values (trim(task_name), false, auth.uid())30 returning id into new_id;3132 return new_id;33end;34$$;3536-- 3. Aggregate function returning stats37create or replace function get_my_stats()38returns table (39 total bigint,40 completed bigint,41 pending bigint42)43language plpgsql44security invoker45as $$46begin47 return query48 select49 count(*)::bigint,50 count(*) filter (where is_complete)::bigint,51 count(*) filter (where not is_complete)::bigint52 from todos53 where user_id = auth.uid();54end;55$$;5657-- 4. Batch operation (security definer for admin)58create or replace function admin_mark_all_complete(target_user_id uuid)59returns integer60language plpgsql61security definer62set search_path = ''63as $$64declare65 affected integer;66begin67 update public.todos68 set is_complete = true69 where user_id = target_user_id70 and is_complete = false;7172 get diagnostics affected = row_count;73 return affected;74end;75$$;7677revoke execute on function admin_mark_all_complete from public, anon;78grant execute on function admin_mark_all_complete to authenticated;Common mistakes when writing Stored Procedures in Supabase
Why it's a problem: Using security definer without setting search_path = '', creating a potential SQL injection vector
How to avoid: Always add 'set search_path = ''' to security definer functions and use fully qualified table names (public.tablename) inside the function body.
Why it's a problem: Expecting RLS to be bypassed when using security invoker (the default)
How to avoid: Security invoker functions run with the calling user's permissions. If RLS blocks the user, the function fails too. Use security definer only when you intentionally need to bypass RLS.
Why it's a problem: Passing JavaScript camelCase parameter names that do not match the SQL snake_case function parameters
How to avoid: Parameter names in supabase.rpc() must match the SQL function parameter names exactly. Use snake_case in both places: supabase.rpc('create_todo', { task_name: 'Buy milk' }).
Why it's a problem: Creating functions in a non-public schema and expecting them to be available via the REST API
How to avoid: Only functions in the public schema are exposed through the auto-generated REST API. If you need a function in another schema, call it via an Edge Function instead.
Best practices
- Use CREATE OR REPLACE so you can update functions without dropping them first
- Default to security invoker — only use security definer when the function genuinely needs to bypass RLS
- Always set search_path = '' on security definer functions and use fully qualified table names
- Use REVOKE and GRANT to control which Postgres roles can execute sensitive functions
- Validate inputs inside PL/pgSQL functions using RAISE EXCEPTION for clear error messages
- Use RETURNS TABLE for complex queries and RETURNS SETOF for returning existing table rows
- Prefer database functions over Edge Functions when the logic is purely data manipulation — they are faster and run inside the database
- Use Edge Functions instead when you need to call external APIs, process files, or run long-running tasks
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I need to write a Supabase stored procedure that calculates the total revenue per customer from an orders table and returns it as a result set. Show me the PL/pgSQL function, how to call it with supabase.rpc(), and explain whether I should use security invoker or definer.
Write a PL/pgSQL function in Supabase that transfers credits between two user accounts atomically. It should debit the sender, credit the receiver, and log the transaction in a transfers table. Use a single transaction so that if any step fails, nothing is committed. Include the RLS policies needed.
Frequently asked questions
What is the difference between a stored procedure and a function in Supabase?
In PostgreSQL (and Supabase), CREATE FUNCTION is the standard way to create reusable database logic. PostgreSQL also supports CREATE PROCEDURE (since version 11) for transaction control, but Supabase's REST API only exposes functions. Use CREATE FUNCTION for everything you want to call from the frontend.
When should I use a database function instead of an Edge Function?
Use database functions for operations that are purely data-related: complex queries, multi-table inserts, aggregations, and validations. Use Edge Functions when you need to call external APIs, process files, send emails, or run logic that takes longer than a database transaction should.
Can I call a stored procedure that performs INSERT operations without RLS policies?
If the function uses security invoker (default), RLS policies must allow the operation. If RLS is enabled with no INSERT policy, the insert will silently fail. Either create an INSERT policy or use security definer to bypass RLS.
How do I debug a stored procedure that returns unexpected results?
Use RAISE NOTICE inside PL/pgSQL functions to log debug messages. Check the Postgres logs in the Dashboard under Logs > Postgres. You can also test functions directly in the SQL Editor with SELECT statements.
Can I use JavaScript or TypeScript for stored procedures?
Not directly in the database. Supabase database functions use SQL or PL/pgSQL. If you need to run TypeScript logic on the server, use an Edge Function instead. You can combine both — an Edge Function that calls a database function via supabase.rpc().
Are stored procedures faster than client-side queries?
Yes, for multi-step operations. A stored procedure runs entirely inside the database in a single round trip, while multiple client-side queries require separate network requests. For single queries, the performance difference is negligible.
Can RapidDev help write stored procedures for my Supabase project?
Yes. RapidDev can design and implement database functions, triggers, and RLS policies for complex business logic, ensuring proper security and performance in your Supabase backend.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation