Cleaning up failed or partial uploads
How to avoid and clean up files left behind when an upload or the database write around it fails, with an upload pattern, SQL to find orphans and a cleanup script.
A Storage upload either completes or fails on its own. The mess usually comes from the step around it: the file uploaded but your app's database row wasn't written, or the row was written but the upload failed. Both leave records that don't match, and orphaned files keep counting toward your storage quota.
What you see
Files in a bucket that no row in your tables points to.
Rows that point to files that don't exist, so the app shows broken images or 404s on download:
{"statusCode":"404","error":"not_found","message":"Object not found"}
Why it happens
An upload and the insert into your own table are two separate requests. Anything between them can fail: the network drops, the user closes the tab, an RLS policy rejects the insert, or the function doing it times out. Nothing rolls the first step back automatically.
How to fix it
Upload first, then write the row, and undo the upload if the row fails:
async function attachFile(invoiceId, file) { const path = `${invoiceId}/${crypto.randomUUID()}-${file.name}` const { error: upErr } = await supabase.storage.from('attachments').upload(path, file) if (upErr) throw upErr const { error: dbErr } = await supabase .from('invoice_attachments') .insert({ invoice_id: invoiceId, path, size_bytes: file.size }) if (dbErr) { await supabase.storage.from('attachments').remove([path]) // best effort throw dbErr } return path }Unique names (a UUID in the path) mean a retry never overwrites another upload.
Find orphaned files that are more than a day old, so uploads still in progress aren't caught:
select o.name from storage.objects o left join public.invoice_attachments a on a.path = o.name where o.bucket_id = 'attachments' and a.id is null and o.created_at < now() - interval '1 day';Find rows whose file is missing:
select a.id, a.path from public.invoice_attachments a left join storage.objects o on o.bucket_id = 'attachments' and o.name = a.path where o.id is null;Delete the orphans through the Storage API. Storage doesn't allow deleting rows from
storage.objectsdirectly with SQL, because the file on disk would stay behind. First wrap the query from step 2 in a function that onlyservice_rolecan run:create or replace function public.orphaned_attachments() returns table (name text) language sql stable set search_path = '' as $$ select o.name from storage.objects o left join public.invoice_attachments a on a.path = o.name where o.bucket_id = 'attachments' and a.id is null and o.created_at < now() - interval '1 day'; $$; revoke execute on function public.orphaned_attachments() from public, anon, authenticated; grant execute on function public.orphaned_attachments() to service_role;Then remove the files from a server with the
service_rolekey:import { createClient } from '@supabase/supabase-js' const admin = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false, autoRefreshToken: false }, }) const { data: rows, error } = await admin.rpc('orphaned_attachments') if (error) throw error const paths = rows.map((r) => r.name) for (let i = 0; i < paths.length; i += 100) { const { error: rmErr } = await admin.storage.from('attachments').remove(paths.slice(i, i + 100)) if (rmErr) throw rmErr } console.log(`Removed ${paths.length} orphaned files`)Run it on a schedule. Deploy the cleanup as an Edge Function and call it from a pg_cron job once a night.
On SupaHost
- The S3 protocol isn't offered on SupaHost, so there are no S3 multipart uploads to abort.
- The "Package files" export on the Backups tab adds a
missing.txtto the archive when somestorage.objectsrows have no file on disk. That's a quick way to spot the second problem across a whole project. - Edge Functions get
SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYautomatically. To run the script there, read them withDeno.env.get()instead ofprocess.envand import the client withnpm:@supabase/supabase-js@2.