To create a custom table in Supabase, use the SQL Editor or the Table Editor in the Dashboard. The SQL Editor gives you full control over data types, constraints, defaults, and foreign keys. After creating the table, always enable Row Level Security and add at least one policy so the table is accessible through the API. Choose UUID or bigint for primary keys, add NOT NULL constraints where needed, and link to auth.users for user-owned data.
Creating Custom Tables in Supabase
Every Supabase project is a full PostgreSQL database, and creating tables is one of the first things you will do after setting up a project. Supabase provides two ways to create tables: the visual Table Editor for quick setup, and the SQL Editor for full control. This tutorial covers both approaches, explains when to use each, and shows you the essential steps that every table needs — primary keys, constraints, RLS, and policies.
Prerequisites
- A Supabase project (free tier works)
- Access to the Supabase Dashboard at app.supabase.com
- Basic understanding of database tables, columns, and data types
Step-by-step guide
Create a table using the SQL Editor
Create a table using the SQL Editor
Open the SQL Editor from the left sidebar in the Dashboard. Write a CREATE TABLE statement with your desired columns, data types, and constraints. This is the recommended approach because it gives you full control over every aspect of the table. Use lowercase names with underscores (snake_case) for both table and column names. Always include a primary key — use UUID for user-facing IDs that appear in URLs, or bigint with auto-increment for internal sequential IDs.
1-- Create a projects table with UUID primary key2CREATE TABLE public.projects (3 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),4 user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,5 name text NOT NULL CHECK (char_length(name) > 0),6 description text,7 status text DEFAULT 'active',8 created_at timestamptz DEFAULT now(),9 updated_at timestamptz DEFAULT now()10);1112-- Alternative: bigint auto-incrementing primary key13CREATE TABLE public.categories (14 id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,15 name text UNIQUE NOT NULL,16 created_at timestamptz DEFAULT now()17);Expected result: The table is created in the public schema and appears in the Table Editor sidebar.
Create a table using the visual Table Editor
Create a table using the visual Table Editor
For a no-code approach, click Table Editor in the left sidebar, then click New Table. Enter the table name, and Supabase automatically adds an id column (bigint) and a created_at column. Click Add Column to add your custom columns — select the data type from the dropdown, set nullable/required, and add default values. You can also add foreign key relationships by clicking the chain link icon on a column. The Table Editor generates the SQL behind the scenes, so you can switch to the SQL Editor later for more complex changes.
Expected result: A new table appears in the Table Editor with the columns you configured, ready for data.
Add a foreign key to link tables
Add a foreign key to link tables
Foreign keys establish relationships between tables. The most important relationship in most Supabase apps is linking a table to auth.users so each row belongs to a specific user. Add a user_id column of type uuid that references auth.users(id). Use ON DELETE CASCADE so that when a user is deleted, their related records are automatically removed. You can also create foreign keys between your own tables to build one-to-many and many-to-many relationships.
1-- Link a table to auth.users (one-to-many: one user has many projects)2ALTER TABLE public.projects3 ADD COLUMN user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE;45-- Link between your own tables (one-to-many: one project has many tasks)6CREATE TABLE public.tasks (7 id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,8 project_id uuid REFERENCES public.projects(id) ON DELETE CASCADE NOT NULL,9 title text NOT NULL,10 completed boolean DEFAULT false,11 created_at timestamptz DEFAULT now()12);Expected result: The foreign key constraint is created, and the Table Editor shows the relationship with a link icon.
Enable Row Level Security on the new table
Enable Row Level Security on the new table
Every table in the public schema should have Row Level Security enabled. Without RLS, anyone with your anon key can read, insert, update, and delete all rows in the table. After enabling RLS with no policies, all access is denied — queries return empty arrays, not errors. This is the intended behavior and the most common source of confusion for new Supabase developers. You must create explicit policies to allow access.
1-- Enable RLS2ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;34-- IMPORTANT: With RLS enabled and no policies, ALL access is denied.5-- You must create at least one policy for the table to be usable via the API.Expected result: RLS is enabled on the table. All API queries return empty results until you add policies in the next step.
Create RLS policies for the table
Create RLS policies for the table
RLS policies define who can perform which operations on which rows. The most common pattern is owner-based access — users can only access rows where user_id matches their own auth.uid(). Create separate policies for SELECT, INSERT, UPDATE, and DELETE, or use a single FOR ALL policy. The USING clause filters which existing rows are visible, and the WITH CHECK clause validates new or updated rows. Always wrap auth.uid() in a subselect for per-statement caching.
1-- Users can view their own projects2CREATE POLICY "Users can view own projects"3 ON public.projects FOR SELECT4 TO authenticated5 USING ((SELECT auth.uid()) = user_id);67-- Users can create projects (user_id must match)8CREATE POLICY "Users can create projects"9 ON public.projects FOR INSERT10 TO authenticated11 WITH CHECK ((SELECT auth.uid()) = user_id);1213-- Users can update their own projects14CREATE POLICY "Users can update own projects"15 ON public.projects FOR UPDATE16 TO authenticated17 USING ((SELECT auth.uid()) = user_id)18 WITH CHECK ((SELECT auth.uid()) = user_id);1920-- Users can delete their own projects21CREATE POLICY "Users can delete own projects"22 ON public.projects FOR DELETE23 TO authenticated24 USING ((SELECT auth.uid()) = user_id);Expected result: Authenticated users can CRUD their own projects. Unauthenticated requests and attempts to access other users' data return empty results.
Verify the table works with the Supabase JS client
Verify the table works with the Supabase JS client
After creating the table with RLS and policies, test it from your application using the Supabase JS client. Insert a record, read it back, and confirm the policies are working correctly. Use the authenticated user's session to ensure the user_id is set correctly. If the insert returns no data or the select returns an empty array, recheck your RLS policies in the Dashboard under Authentication > Policies.
1import { supabase } from '@/lib/supabase'23// Insert a new project (user must be authenticated)4const { data: { user } } = await supabase.auth.getUser()56const { data, error } = await supabase7 .from('projects')8 .insert({9 user_id: user.id,10 name: 'My First Project',11 description: 'Testing the new table'12 })13 .select()1415console.log('Inserted:', data)1617// Read back all projects for the current user18const { data: projects } = await supabase19 .from('projects')20 .select('*')21 .order('created_at', { ascending: false })2223console.log('My projects:', projects)Expected result: The insert succeeds, and the select returns the newly created project. The table is fully functional through the API.
Complete working example
1-- =============================================2-- Complete table creation with all best practices3-- Save as: supabase/migrations/create_projects.sql4-- =============================================56-- 1. Create the table7CREATE TABLE public.projects (8 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),9 user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,10 name text NOT NULL CHECK (char_length(name) > 0),11 description text,12 status text DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleted')),13 created_at timestamptz DEFAULT now(),14 updated_at timestamptz DEFAULT now()15);1617-- 2. Enable Row Level Security18ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;1920-- 3. Create RLS policies21CREATE POLICY "Users can view own projects"22 ON public.projects FOR SELECT TO authenticated23 USING ((SELECT auth.uid()) = user_id);2425CREATE POLICY "Users can create projects"26 ON public.projects FOR INSERT TO authenticated27 WITH CHECK ((SELECT auth.uid()) = user_id);2829CREATE POLICY "Users can update own projects"30 ON public.projects FOR UPDATE TO authenticated31 USING ((SELECT auth.uid()) = user_id)32 WITH CHECK ((SELECT auth.uid()) = user_id);3334CREATE POLICY "Users can delete own projects"35 ON public.projects FOR DELETE TO authenticated36 USING ((SELECT auth.uid()) = user_id);3738-- 4. Add performance indexes39CREATE INDEX idx_projects_user_id ON public.projects (user_id);40CREATE INDEX idx_projects_status ON public.projects (status);4142-- 5. Auto-update the updated_at timestamp43CREATE OR REPLACE FUNCTION public.update_updated_at()44RETURNS TRIGGER LANGUAGE plpgsql AS $$45BEGIN46 NEW.updated_at = now();47 RETURN NEW;48END;49$$;5051CREATE TRIGGER set_updated_at52 BEFORE UPDATE ON public.projects53 FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();Common mistakes when creating a Custom Table in Supabase
Why it's a problem: Creating a table without enabling RLS, leaving all data publicly accessible via the API
How to avoid: Always run ALTER TABLE your_table ENABLE ROW LEVEL SECURITY immediately after creating any table in the public schema.
Why it's a problem: Enabling RLS but not creating any policies, which blocks all access and causes empty query results
How to avoid: After enabling RLS, create at least a SELECT policy for the table. Empty results with no errors almost always means missing policies.
Why it's a problem: Using uppercase or mixed-case table and column names which require quoting everywhere
How to avoid: Always use lowercase with underscores (snake_case) for table and column names. PostgreSQL folds unquoted identifiers to lowercase, so mixed-case names cause confusion.
Why it's a problem: Not adding ON DELETE CASCADE to the auth.users foreign key, causing errors when users are deleted
How to avoid: Add ON DELETE CASCADE when creating the foreign key: REFERENCES auth.users(id) ON DELETE CASCADE. This ensures user data is cleaned up automatically.
Best practices
- Always create tables in the public schema — this schema is automatically exposed through the Supabase REST API
- Enable RLS on every public table and create explicit policies before using it in production
- Use UUID primary keys for tables where IDs appear in URLs to prevent enumeration attacks
- Add NOT NULL constraints on columns that should always have a value to catch errors early
- Include created_at and updated_at timestamps with sensible defaults on every table
- Link user-owned tables to auth.users via a user_id foreign key with ON DELETE CASCADE
- Add CHECK constraints for columns with limited valid values (e.g., status IN ('active', 'archived'))
- Create indexes on columns used in WHERE clauses, JOINs, and RLS policies
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I need to create a custom table in Supabase for a project management app. The table should have projects with a name, description, status, and link to auth.users. Show the complete SQL including RLS policies and indexes.
Create a projects table in the Supabase SQL Editor with columns for id (UUID), user_id (linked to auth.users), name, description, status, and timestamps. Enable RLS and add policies for authenticated users to CRUD their own projects. Include indexes for performance.
Frequently asked questions
Should I use the Table Editor or the SQL Editor to create tables?
Use the SQL Editor for production tables because it gives you full control over constraints, indexes, and RLS policies. Use the Table Editor for quick prototyping or when you are learning the available data types.
Should I use UUID or bigint for primary keys?
Use UUID (gen_random_uuid()) when IDs will be visible to users, such as in URLs, because they cannot be guessed sequentially. Use bigint with GENERATED ALWAYS AS IDENTITY for internal tables where a simple sequential ID is sufficient.
Can I change a table after creating it?
Yes. Use ALTER TABLE to add columns, modify constraints, and change defaults. You can also use the Table Editor to add columns visually. For production apps, make changes through migration files so they are tracked in version control.
Why do my queries return empty results after creating a table?
If RLS is enabled and you have not created any policies, all access is denied and queries return empty arrays (not errors). This is the most common issue new Supabase developers encounter. Create at least a SELECT policy.
Do I need to create tables for auth.users and storage.objects?
No. Supabase creates these automatically. The auth and storage schemas are managed internally. Create your own tables in the public schema and reference auth.users via foreign keys.
Can I create tables using the Supabase CLI?
Yes. Create a migration file with supabase migration new create_my_table, write your CREATE TABLE SQL in the generated file, then apply it locally with supabase migration up or deploy with supabase db push.
Can RapidDev help me design my database schema in Supabase?
Yes. RapidDev's engineering team can help you design an optimal database schema with proper relationships, indexes, RLS policies, and migration workflows for your Supabase project.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation