Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Write Stored Procedures in Supabase

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.

What you'll learn

  • How to create PL/pgSQL functions in the Supabase SQL Editor
  • How to call stored procedures from JavaScript using supabase.rpc()
  • How security invoker vs security definer affects RLS and permissions
  • When to use database functions vs Edge Functions
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner8 min read10-15 minSupabase (all plans), @supabase/supabase-js v2+, PostgreSQL 15+March 2026RapidDev Engineering Team
TL;DR

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

1

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().

typescript
1-- Simple function that returns a greeting
2create or replace function hello_world()
3returns text
4language sql
5as $$
6 select 'Hello from Supabase!';
7$$;

Expected result: The function is created and visible in Database > Functions in the Dashboard.

2

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.

typescript
1create or replace function create_todo(task_name text, user_uuid uuid)
2returns bigint
3language plpgsql
4as $$
5declare
6 new_id bigint;
7begin
8 insert into todos (task, is_complete, user_id)
9 values (task_name, false, user_uuid)
10 returning id into new_id;
11
12 return new_id;
13end;
14$$;

Expected result: The function is created and accepts task_name and user_uuid parameters, returning the new record ID.

3

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.

typescript
1import { createClient } from '@supabase/supabase-js'
2
3const supabase = createClient(
4 'https://your-project.supabase.co',
5 'your-anon-key'
6)
7
8// Call a simple function
9const { data: greeting } = await supabase.rpc('hello_world')
10console.log(greeting) // 'Hello from Supabase!'
11
12// Call a function with parameters
13const { data: newId, error } = await supabase.rpc('create_todo', {
14 task_name: 'Buy groceries',
15 user_uuid: 'a1b2c3d4-...'
16})
17
18if (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.

4

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.

typescript
1-- Function that returns a filtered result set
2create or replace function get_user_stats(target_user_id uuid)
3returns table (
4 total_todos bigint,
5 completed_todos bigint,
6 completion_rate numeric
7)
8language plpgsql
9as $$
10begin
11 return query
12 select
13 count(*)::bigint as total_todos,
14 count(*) filter (where is_complete = true)::bigint as completed_todos,
15 case
16 when count(*) > 0
17 then round((count(*) filter (where is_complete = true)::numeric / count(*)::numeric) * 100, 1)
18 else 0
19 end as completion_rate
20 from todos
21 where user_id = target_user_id;
22end;
23$$;
24
25-- Call from JavaScript
26-- 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.

5

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.

typescript
1-- SECURITY INVOKER (default, recommended)
2-- RLS policies apply, runs as the calling user
3create or replace function get_my_profile()
4returns json
5language plpgsql
6security invoker
7as $$
8begin
9 return (
10 select row_to_json(p)
11 from profiles p
12 where p.id = auth.uid()
13 );
14end;
15$$;
16
17-- SECURITY DEFINER (elevated privileges)
18-- Bypasses RLS, runs as function owner
19-- MUST set search_path = '' for security
20create or replace function admin_create_user_profile(
21 target_user_id uuid,
22 display_name text
23)
24returns void
25language plpgsql
26security definer
27set search_path = ''
28as $$
29begin
30 insert into public.profiles (id, name)
31 values (target_user_id, display_name);
32end;
33$$;
34
35-- Restrict who can call the definer function
36revoke 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

migration.sql
1-- Complete example: Stored procedures for a todo app
2-- Run this in the Supabase SQL Editor
3
4-- 1. Simple query function (security invoker = respects RLS)
5create or replace function get_my_todos()
6returns setof todos
7language sql
8security invoker
9as $$
10 select * from todos
11 where user_id = auth.uid()
12 order by created_at desc;
13$$;
14
15-- 2. Insert function with validation
16create or replace function create_todo(task_name text)
17returns bigint
18language plpgsql
19security invoker
20as $$
21declare
22 new_id bigint;
23begin
24 if trim(task_name) = '' then
25 raise exception 'Task name cannot be empty';
26 end if;
27
28 insert into todos (task, is_complete, user_id)
29 values (trim(task_name), false, auth.uid())
30 returning id into new_id;
31
32 return new_id;
33end;
34$$;
35
36-- 3. Aggregate function returning stats
37create or replace function get_my_stats()
38returns table (
39 total bigint,
40 completed bigint,
41 pending bigint
42)
43language plpgsql
44security invoker
45as $$
46begin
47 return query
48 select
49 count(*)::bigint,
50 count(*) filter (where is_complete)::bigint,
51 count(*) filter (where not is_complete)::bigint
52 from todos
53 where user_id = auth.uid();
54end;
55$$;
56
57-- 4. Batch operation (security definer for admin)
58create or replace function admin_mark_all_complete(target_user_id uuid)
59returns integer
60language plpgsql
61security definer
62set search_path = ''
63as $$
64declare
65 affected integer;
66begin
67 update public.todos
68 set is_complete = true
69 where user_id = target_user_id
70 and is_complete = false;
71
72 get diagnostics affected = row_count;
73 return affected;
74end;
75$$;
76
77revoke 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.