# How to Insert Data into a Supabase Table

- Tool: Supabase
- Difficulty: Beginner
- Time required: 10-15 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+
- Last updated: March 2026

## TL;DR

To insert data into a Supabase table, use supabase.from('table_name').insert() with a single object or an array for bulk inserts. The insert respects Row Level Security, so you need an INSERT policy granting the authenticated role permission. Use upsert() with an onConflict option to handle duplicates. Always check the returned error object and use .select() chained after insert to get the created row back.

## Inserting Data into Supabase Tables

Supabase provides a JavaScript client that wraps the auto-generated PostgREST API to perform CRUD operations on your PostgreSQL database. This tutorial covers inserting single rows, bulk inserts, upserts for handling duplicates, and the RLS policies required to allow inserts from your frontend application.

## Before you start

- A Supabase project with at least one table created
- The Supabase JS client installed (@supabase/supabase-js v2+)
- RLS enabled on the target table
- A signed-in user (if your INSERT policy targets the authenticated role)

## Step-by-step guide

### 1. Create a table and enable RLS

Before inserting data, you need a table with RLS enabled. Use the SQL Editor in the Supabase Dashboard to create a table and enable RLS. Once RLS is enabled with no policies, all access is denied by default — inserts will silently fail (return no error but insert nothing) unless you add an INSERT policy.

```
create table public.todos (
  id bigint generated always as identity primary key,
  user_id uuid references auth.users not null default auth.uid(),
  title text not null,
  completed boolean default false,
  created_at timestamptz default now()
);

alter table public.todos enable row level security;
```

**Expected result:** A todos table exists with RLS enabled and no access policies yet.

### 2. Write an INSERT RLS policy

Create an RLS policy that allows authenticated users to insert rows. The WITH CHECK clause validates the new row being inserted. A common pattern is to ensure the user_id column matches the authenticated user's ID, preventing users from creating records on behalf of other users. If your table has a default of auth.uid() on user_id, the policy still needs to verify it.

```
-- Allow authenticated users to insert their own todos
create policy "Users can insert their own todos"
on public.todos for insert
to authenticated
with check (
  (select auth.uid()) = user_id
);

-- Also add a SELECT policy so inserted rows can be returned
create policy "Users can view their own todos"
on public.todos for select
to authenticated
using (
  (select auth.uid()) = user_id
);
```

**Expected result:** Authenticated users can insert rows where user_id matches their own auth ID.

### 3. Insert a single row from the JS client

Use supabase.from('table').insert() to create a new row. Pass an object with the column names as keys. Chain .select() after insert to return the created row. Without .select(), the insert succeeds but returns no data. Always check the error object — a null error means the insert succeeded.

```
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

// Insert a single row and return the created record
const { data, error } = await supabase
  .from('todos')
  .insert({ title: 'Buy groceries' })
  .select()

if (error) {
  console.error('Insert error:', error.message)
} else {
  console.log('Created todo:', data[0])
}
```

**Expected result:** A new row is inserted into the todos table and the created record is returned with all columns including the auto-generated id and timestamps.

### 4. Insert multiple rows in a single call

Pass an array of objects to insert() to create multiple rows in one database round-trip. This is significantly faster than calling insert() in a loop for each row. All rows in the array are inserted in a single transaction, so either all succeed or all fail.

```
// Bulk insert multiple todos
const { data, error } = await supabase
  .from('todos')
  .insert([
    { title: 'Buy groceries' },
    { title: 'Walk the dog' },
    { title: 'Finish project report' },
  ])
  .select()

if (error) {
  console.error('Bulk insert error:', error.message)
} else {
  console.log(`Inserted ${data.length} todos`)
}
```

**Expected result:** All rows in the array are inserted in a single transaction and the created records are returned.

### 5. Use upsert to handle duplicate conflicts

The upsert() method inserts a row if it does not exist, or updates it if a row with the same unique/primary key already exists. Specify the onConflict option with the column name that has the unique constraint. This is useful for syncing data from external sources or implementing 'save' functionality where the row may or may not already exist.

```
// Upsert: insert or update if title already exists
// (requires a unique constraint on the title column)
const { data, error } = await supabase
  .from('todos')
  .upsert(
    { title: 'Buy groceries', completed: true },
    { onConflict: 'title' }
  )
  .select()

if (error) {
  console.error('Upsert error:', error.message)
} else {
  console.log('Upserted:', data[0])
}
```

**Expected result:** If a row with the same title exists, it is updated with completed: true. Otherwise, a new row is created.

## Complete code example

File: `insert-data.ts`

```typescript
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

// Insert a single row
async function createTodo(title: string) {
  const { data, error } = await supabase
    .from('todos')
    .insert({ title })
    .select()
    .single()

  if (error) throw new Error(`Insert failed: ${error.message}`)
  return data
}

// Insert multiple rows
async function createTodos(titles: string[]) {
  const rows = titles.map((title) => ({ title }))

  const { data, error } = await supabase
    .from('todos')
    .insert(rows)
    .select()

  if (error) throw new Error(`Bulk insert failed: ${error.message}`)
  return data
}

// Upsert (insert or update on conflict)
async function upsertTodo(title: string, completed: boolean) {
  const { data, error } = await supabase
    .from('todos')
    .upsert({ title, completed }, { onConflict: 'title' })
    .select()
    .single()

  if (error) throw new Error(`Upsert failed: ${error.message}`)
  return data
}

// Insert with explicit user_id (server-side with service role)
async function adminCreateTodo(userId: string, title: string) {
  const adminClient = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  )

  const { data, error } = await adminClient
    .from('todos')
    .insert({ user_id: userId, title })
    .select()
    .single()

  if (error) throw new Error(`Admin insert failed: ${error.message}`)
  return data
}

// === Usage ===
const todo = await createTodo('Buy groceries')
console.log('Created:', todo)

const todos = await createTodos(['Walk the dog', 'Read a book'])
console.log('Bulk created:', todos.length, 'todos')

const upserted = await upsertTodo('Buy groceries', true)
console.log('Upserted:', upserted)
```

## Common mistakes

- **Insert succeeds with no error but the row does not appear in the table** — undefined Fix: This is almost always an RLS issue. When RLS is enabled with no INSERT policy, the insert is silently blocked. Add an INSERT policy for the authenticated role with the correct WITH CHECK clause.
- **Forgetting to chain .select() after insert and getting null data back** — undefined Fix: By default, insert() does not return the created row. Chain .select() to get the inserted data back, or .select().single() if you inserted one row and want a single object instead of an array.
- **Using the service role key in client-side code to bypass RLS for inserts** — undefined Fix: The service role key bypasses all RLS and must NEVER be used in browser code. Create proper INSERT policies instead. Use the service role key only in server-side code like Edge Functions or API routes.
- **Not handling the unique constraint violation error on upsert without onConflict** — undefined Fix: If you call upsert without specifying onConflict and there is no unique constraint on the table's primary key for the given data, PostgreSQL does not know which column to check for conflicts. Always specify onConflict with the column name.

## Best practices

- Always enable RLS on tables and write explicit INSERT policies before inserting data from the client
- Use column defaults (like auth.uid() for user_id) to reduce client-side data that must be sent
- Chain .select() after insert to get the created row back, including auto-generated columns
- Use .single() when inserting one row to get a single object instead of an array
- Batch multiple inserts into a single array call instead of looping insert() calls
- Use upsert with onConflict for idempotent operations that may be retried
- Validate data on the client before inserting to provide instant feedback
- Use the service role key only on the server side for admin operations that bypass RLS

## Frequently asked questions

### Why does my insert return no error but the row doesn't appear?

This is the most common Supabase issue. When RLS is enabled with no INSERT policy, inserts are silently blocked — no error is thrown but no row is created. Add an INSERT policy for the authenticated role.

### How do I get the auto-generated ID after insert?

Chain .select() after insert(): supabase.from('table').insert({ title: 'test' }).select(). The returned data includes all columns, including auto-generated id and timestamp fields.

### Can I insert data without a user being signed in?

Only if your INSERT policy targets the anon role (unauthenticated requests). By default, most policies target the authenticated role, requiring a signed-in user. For public data collection (like a contact form), create a policy for the anon role.

### What is the difference between insert and upsert?

insert() always creates a new row and fails if a unique constraint is violated. upsert() creates a new row if no conflict exists, or updates the existing row if a matching unique key is found. Use upsert for idempotent operations.

### How many rows can I insert in a single bulk call?

There is no hard limit on the number of rows per insert call, but very large batches may hit timeout or memory limits. For best performance, batch inserts in groups of 500-1000 rows.

### Can I insert data into related tables in one call?

No, the Supabase JS client does not support multi-table inserts in a single call. Insert into the parent table first, get the ID, then insert into the child table. For atomic multi-table inserts, use a database function called via supabase.rpc().

### Can RapidDev help build a data management layer with Supabase?

Yes, RapidDev can design your database schema, write RLS policies, and implement a complete data access layer with inserts, updates, deletes, and real-time subscriptions tailored to your application.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-insert-data-into-supabase-table
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-insert-data-into-supabase-table
