Avoid select('*') — Request Only the Columns You Need
Impact: HIGH (reduces payload size 2-10x, enables index-only scans, prevents data leakage)
Using .select('*') in Supabase queries fetches every column from the table, including large text, jsonb, and bytea fields that your UI may never render. This creates three problems:
Wasted bandwidth — large columns (markdown content, JSON metadata, file data) inflate every response. A table with a content column averaging 5KB per row turns a 100-row list query from ~20KB to ~500KB.
No index-only scans — PostgreSQL can satisfy queries entirely from an index if you only request indexed columns. SELECT * forces a heap fetch for every row, which is significantly slower on large tables.
Data leakage — even with RLS, select('*') exposes the full table schema and may return sensitive columns (internal notes, soft-delete flags, audit fields) that the frontend should never see.
Incorrect (select all columns for a list view):
// ❌ Fetches all columns including large 'content' and 'metadata' fieldsexport async function getArticles(supabase: SupabaseClient) { const { data, error } = await supabase .from('articles') .select('*') .eq('published', true) .order('published_at', { ascending: false }) .limit(20) if (error) throw error return data}// Each article has a 'content' column (avg 8KB of markdown)// and a 'metadata' column (avg 2KB of JSON)// List view only shows title, excerpt, and date// Payload: ~200KB instead of ~15KB
Incorrect (select all in server component data fetching):
// ✅ Request only columns the list view actually rendersexport async function getArticles(supabase: SupabaseClient) { const { data, error } = await supabase .from('articles') .select('id, title, excerpt, slug, published_at, author:profiles(full_name, avatar_url)') .eq('published', true) .order('published_at', { ascending: false }) .limit(20) if (error) throw error return data}// Payload: ~15KB — only the fields rendered in the card grid// PostgreSQL can use an index-only scan on (published, published_at) covering (id, title, excerpt, slug)
Correct (targeted select for member list):
// ✅ Only the 3 columns actually used in the UIasync function MemberList({ teamId }: { teamId: string }) { const supabase = await createClient() const { data: members, error } = await supabase .from('team_members') .select(` role, user:profiles ( id, full_name, avatar_url ) `) .eq('team_id', teamId) .order('role') if (error) throw error return ( <ul> {members?.map((m) => ( <li key={m.user.id}> <Avatar url={m.user.avatar_url} /> <span>{m.user.full_name}</span> <Badge>{m.role}</Badge> </li> ))} </ul> )}// No sensitive fields ever leave the database
Correct (full detail view — select('*') is acceptable here):
// ✅ Fetching all columns is fine for a single-record detail viewexport async function getArticleBySlug(supabase: SupabaseClient, slug: string) { const { data, error } = await supabase .from('articles') .select(` id, title, content, excerpt, slug, published_at, metadata, author:profiles ( id, full_name, avatar_url, bio ) `) .eq('slug', slug) .single() if (error) throw error return data}// Single record — even with large content column, this is fine// Still explicitly name columns to avoid leaking internal fields
Detection hints:
# Find all select('*') callsgrep -rn "select('*')" src/ --include="*.ts" --include="*.tsx"# Find select("*") with double quotesgrep -rn 'select("*")' src/ --include="*.ts" --include="*.tsx"# Find .select() with no arguments (defaults to *)grep -rn "\.select()" src/ --include="*.ts" --include="*.tsx"