Medium Impact

Use UPSERT for Insert-or-Update Operations

Atomic insert-or-update with better performance

Why This Matters

Many applications need to insert a row if it doesn't exist, or update it if it does. Using separate INSERT and UPDATE statements creates race conditions and requires multiple round-trips to the database.

UPSERT operations are essential for:

Warning: Using separate INSERT/UPDATE statements creates race conditions. Two concurrent requests can both try to INSERT, causing constraint violations or duplicate key errors.

The Problem

Using separate INSERT and UPDATE statements creates race conditions and requires multiple queries:

Incorrect - Separate INSERT/UPDATE with race condition
-- Application code: Check if exists, then insert or update
SELECT * FROM user_preferences WHERE user_id = 123;

if (exists) {
  UPDATE user_preferences 
  SET theme = 'dark', language = 'en'
  WHERE user_id = 123;
} else {
  INSERT INTO user_preferences (user_id, theme, language)
  VALUES (123, 'dark', 'en');
}

-- Problem: Race condition if two requests run concurrently
-- Both SELECT return no rows, both try INSERT, one fails
-- Also: 2-3 round-trips to database

This pattern is not atomic and can fail under concurrent load. It also requires multiple database round-trips.

The Solution

Use INSERT ... ON CONFLICT (UPSERT) to atomically insert or update:

Correct - Atomic UPSERT operation
-- Single atomic operation: insert or update
INSERT INTO user_preferences (user_id, theme, language)
VALUES (123, 'dark', 'en')
ON CONFLICT (user_id)
DO UPDATE SET
  theme = EXCLUDED.theme,
  language = EXCLUDED.language;

-- Result: Atomic operation, no race conditions
-- Single round-trip to database
-- Works correctly under concurrent load

The ON CONFLICT clause specifies which unique constraint to check. When a conflict occurs, DO UPDATE runs instead of failing.

For partial updates (only update specified columns), use EXCLUDED to reference the values that would have been inserted:

-- Update only specific columns on conflict
INSERT INTO user_preferences (user_id, theme, language, last_login)
VALUES (123, 'dark', 'en', NOW())
ON CONFLICT (user_id)
DO UPDATE SET
  theme = COALESCE(EXCLUDED.theme, user_preferences.theme),
  last_login = EXCLUDED.last_login;

-- COALESCE preserves existing theme if new value is NULL

Best Practice: Always specify the conflict target explicitly (e.g., ON CONFLICT (user_id) or ON CONFLICT ON CONSTRAINT user_preferences_pkey). This makes the intent clear and ensures correct behavior.

Result: UPSERT eliminates race conditions and reduces database round-trips from 2-3 to 1. Operations are atomic and safe under concurrent load.

Check Your Project Automatically

Connect your Supabase project to detect inefficient insert-or-update patterns and other performance issues.

Start Supabase Audit

Common Mistakes

Advanced Patterns

Use RETURNING to determine if an insert or update occurred:

-- Return the final row and operation type
WITH upserted AS (
  INSERT INTO user_preferences (user_id, theme, language)
  VALUES (123, 'dark', 'en')
  ON CONFLICT (user_id)
  DO UPDATE SET
    theme = EXCLUDED.theme,
    language = EXCLUDED.language
  RETURNING *, (xmax = 0) AS inserted
)
SELECT * FROM upserted;

-- xmax = 0 means row was inserted, xmax > 0 means updated

For incrementing counters atomically:

-- Increment a counter, creating it if it doesn't exist
INSERT INTO page_views (page_id, view_count)
VALUES (456, 1)
ON CONFLICT (page_id)
DO UPDATE SET
  view_count = page_views.view_count + 1,
  last_viewed = NOW();

Learn More