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

How to Create a Custom Table in Supabase

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.

What you'll learn

  • How to create tables using the SQL Editor and the visual Table Editor
  • How to choose between UUID and bigint primary keys
  • How to add foreign keys linking to auth.users and other tables
  • How to enable RLS and create basic access policies for new tables
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner9 min read10-15 minSupabase (all plans), Dashboard Table Editor and SQL EditorMarch 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1-- Create a projects table with UUID primary key
2CREATE 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);
11
12-- Alternative: bigint auto-incrementing primary key
13CREATE 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.

2

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.

3

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.

typescript
1-- Link a table to auth.users (one-to-many: one user has many projects)
2ALTER TABLE public.projects
3 ADD COLUMN user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE;
4
5-- 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.

4

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.

typescript
1-- Enable RLS
2ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
3
4-- 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.

5

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.

typescript
1-- Users can view their own projects
2CREATE POLICY "Users can view own projects"
3 ON public.projects FOR SELECT
4 TO authenticated
5 USING ((SELECT auth.uid()) = user_id);
6
7-- Users can create projects (user_id must match)
8CREATE POLICY "Users can create projects"
9 ON public.projects FOR INSERT
10 TO authenticated
11 WITH CHECK ((SELECT auth.uid()) = user_id);
12
13-- Users can update their own projects
14CREATE POLICY "Users can update own projects"
15 ON public.projects FOR UPDATE
16 TO authenticated
17 USING ((SELECT auth.uid()) = user_id)
18 WITH CHECK ((SELECT auth.uid()) = user_id);
19
20-- Users can delete their own projects
21CREATE POLICY "Users can delete own projects"
22 ON public.projects FOR DELETE
23 TO authenticated
24 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.

6

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.

typescript
1import { supabase } from '@/lib/supabase'
2
3// Insert a new project (user must be authenticated)
4const { data: { user } } = await supabase.auth.getUser()
5
6const { data, error } = await supabase
7 .from('projects')
8 .insert({
9 user_id: user.id,
10 name: 'My First Project',
11 description: 'Testing the new table'
12 })
13 .select()
14
15console.log('Inserted:', data)
16
17// Read back all projects for the current user
18const { data: projects } = await supabase
19 .from('projects')
20 .select('*')
21 .order('created_at', { ascending: false })
22
23console.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

create-table-migration.sql
1-- =============================================
2-- Complete table creation with all best practices
3-- Save as: supabase/migrations/create_projects.sql
4-- =============================================
5
6-- 1. Create the table
7CREATE 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);
16
17-- 2. Enable Row Level Security
18ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
19
20-- 3. Create RLS policies
21CREATE POLICY "Users can view own projects"
22 ON public.projects FOR SELECT TO authenticated
23 USING ((SELECT auth.uid()) = user_id);
24
25CREATE POLICY "Users can create projects"
26 ON public.projects FOR INSERT TO authenticated
27 WITH CHECK ((SELECT auth.uid()) = user_id);
28
29CREATE POLICY "Users can update own projects"
30 ON public.projects FOR UPDATE TO authenticated
31 USING ((SELECT auth.uid()) = user_id)
32 WITH CHECK ((SELECT auth.uid()) = user_id);
33
34CREATE POLICY "Users can delete own projects"
35 ON public.projects FOR DELETE TO authenticated
36 USING ((SELECT auth.uid()) = user_id);
37
38-- 4. Add performance indexes
39CREATE INDEX idx_projects_user_id ON public.projects (user_id);
40CREATE INDEX idx_projects_status ON public.projects (status);
41
42-- 5. Auto-update the updated_at timestamp
43CREATE OR REPLACE FUNCTION public.update_updated_at()
44RETURNS TRIGGER LANGUAGE plpgsql AS $$
45BEGIN
46 NEW.updated_at = now();
47 RETURN NEW;
48END;
49$$;
50
51CREATE TRIGGER set_updated_at
52 BEFORE UPDATE ON public.projects
53 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

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.