How to Drop All Tables in PostgreSQL Safely (2026)
Dropping tables one by one is painful. Here is the one-command reset, the PostgreSQL 15 permission gotcha that breaks it, and the safe way to do it on Supabase.
Why you are here#
You ran a migration that went sideways, or you are resetting a local dev
database, or a test suite needs a clean slate. The psql console has 40 tables
and you do not want to type DROP TABLE 40 times.
There is a one-command way to do it. There is also a PostgreSQL 15 change that silently breaks it the first time you try, and a Supabase-specific caveat that makes the naive command dangerous on a hosted project. This article is the reset path I use, with the gotchas inline.
The original Stack Overflow thread ("How can I drop all the tables in a PostgreSQL database?") has been viewed more than 1.5 million times for a reason: every developer hits this during a migration at least once.
The one-command reset#
-- ⚠️ Destructive. Drops every table, view, sequence, and function in public.
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO postgres;
GRANT ALL ON SCHEMA public TO public;DROP SCHEMA public CASCADE removes the public schema and every object that
depends on it — tables, views, materialized views, sequences, functions, and
triggers. CASCADE is what saves you from listing the dependencies in order.
Then you recreate the empty schema and restore the default grants.
That second GRANT ... TO public line is where the PostgreSQL 15 gotcha lives.
The PostgreSQL 15 gotcha that breaks this#
Per the official PostgreSQL 15.0 release notes, PostgreSQL 15 changed two defaults on the public schema, and both of them
bite you when you drop and recreate it:
PUBLICno longer hasCREATEonpublicby default. Before 15, every role could create objects inpublic. From 15 onward, on new databases and new clusters, that privilege is revoked. This is the secure-schema pattern PostgreSQL has recommended since CVE-2018-1058, now made the default.- The owner of
publicis nowpg_database_owner, not the bootstrap superuser. This lets database owners managepublicwithout being a superuser.
When you run CREATE SCHEMA public on a 15+ database, the schema comes back
with these new secure defaults. Your application role — the one that runs
INSERT, CREATE TABLE, migrations — no longer has CREATE on public. The
next migration or insert throws:
ERROR: permission denied for schema publicIf you already hit that, the fix is to grant explicitly to the role your app
actually uses, instead of relying on PUBLIC:
CREATE SCHEMA public;
GRANT USAGE ON SCHEMA public TO app_role;
GRANT CREATE ON SCHEMA public TO app_role;On Supabase, the anon and authenticated roles also need USAGE on public or
your client queries fail with the same error. For a deeper write-up of that
specific failure, see Supabase client "permission denied for schema public"
fix.
The variant that keeps extensions#
DROP SCHEMA public CASCADE also drops every extension installed in the
public schema. uuid-ossp, pgcrypto, pg_stat_statements (if installed
there), and — critically on Supabase — pgvector all live somewhere. If you
rely on gen_random_uuid(), uuid_generate_v4(), or vector columns, they
vanish and you get:
ERROR: function gen_random_uuid() does not existReinstall them after recreating the schema:
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO postgres;
-- Reinstall the extensions you actually use.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS vector; -- pgvectorIf you never want to lose extensions during a reset, drop the tables only and leave the schema (and its extensions) intact — see the selective script below.
Drop only tables, keep everything else#
When extensions and functions must survive the reset, drop tables individually
via information_schema instead of nuking the whole schema:
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;This loops over every table in public and drops it with CASCADE (so views
and foreign keys depending on it go too). Functions, sequences created by
SERIAL columns (they get dropped with their tables), and extensions stay.
If you reload data with explicit id values (a dump, a seed file), the
sequences do not move with it: the next INSERT without an id then takes a
value that already exists and fails with duplicate key value violates unique constraint. Point each sequence one past the highest id instead of back at 1:
DO $$
DECLARE
r RECORD;
seq text;
BEGIN
FOR r IN
SELECT c.table_name
FROM information_schema.columns c
JOIN pg_tables t ON t.schemaname = c.table_schema AND t.tablename = c.table_name
WHERE c.table_schema = 'public' AND c.column_name = 'id'
LOOP
seq := pg_get_serial_sequence(format('public.%I', r.table_name), 'id');
IF seq IS NOT NULL THEN
EXECUTE format(
'SELECT setval(%L, COALESCE((SELECT max(id) FROM public.%I), 0) + 1, false)',
seq, r.table_name);
END IF;
END LOOP;
END $$;The loop only visits tables that have an id column, because
pg_get_serial_sequence raises an error for a column that does not exist. On
PostgreSQL 18.6, with three rows reloaded as ids 1 to 3, the next insert got
id 4 and an empty table started at 1; resetting every sequence to 1 instead
made that same insert fail on Key (id)=(1) already exists.
The Supabase path: do not run DROP SCHEMA on production#
On a hosted Supabase project, public is not the only schema that matters. The
auth, storage, realtime, and graphql schemas are all managed by Supabase
and reference objects that your tables depend on. Running
DROP SCHEMA public CASCADE on a production project can cascade into Supabase's
internal objects and break the dashboard, auth, and storage.
For a dev project, use the controlled reset:
# CLI: drops and rebuilds everything from your migrations folder
supabase db resetPer the official CLI reference, this recreates the local Postgres container and reapplies every migration in supabase/migrations, discarding any other schema or data changes made outside them.
Or in the dashboard: Project Settings → Database → Reset database. Both rebuild the schema from your migration files, which is the safe path — your schema is reproducible from code, not from memory.
For production, never reset. Restore from a backup or a pg_dump snapshot:
pg_dump "$DATABASE_URL" -F c -f backup.dump
# ... later, to restore:
pg_restore -d "$DATABASE_URL" -c backup.dumpIf the reset is part of a failed migration, follow the PostgreSQL migration rollback playbook instead of dropping tables by hand.
The transaction wrapper for dev#
On a local database, wrap the reset in a transaction so a typo does not leave you in a half-dropped state:
BEGIN;
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO postgres;
-- sanity check: SELECT 1;
COMMIT;
-- if something looked wrong: ROLLBACK; (but the schema is already gone)Note: DROP/CREATE SCHEMA inside a transaction is safe to ROLLBACK —
PostgreSQL transactional DDL means a rollback restores the dropped schema. This
is one of the features that makes Postgres migrations far less scary than other
engines.
Production safety checklist#
Before you run any of this against anything that is not a throwaway dev database:
- Take a backup first.
pg_dump -F cis cheap insurance. - Confirm the connection string.
\conninfoinpsql— verify you are not pointed at prod. The number of times a reset has hit the wrong database is exactly why this section exists. - Block new connections during the reset if other services share the DB:
ALTER DATABASE yourdb CONNECTION LIMIT 0;then terminate backends, reset, then restore the limit. - Have a tested restore path. A backup you have never restored from is a wish, not a backup.
For the broader posture — when to roll forward vs roll back, how to stage migrations so a reset is never the plan — read the database design and optimization guide.
Common mistakes#
- Forgetting
CASCADE. Without it,DROP SCHEMArefuses to run if any object exists in it.CASCADEis the whole point. - Running it in the wrong database. Use
SELECT current_database();to confirm before you press enter.\llists databases. - Not re-granting on PostgreSQL 15+. The schema recreates with secure
defaults and your app role loses
CREATE. See the gotcha section above. - Dropping on a hosted Supabase prod. Use
supabase db reseton dev only. - Dropping extensions by accident. Reinstall
uuid-ossp,pgcrypto,pgvectorafterCREATE SCHEMA, or use the table-only loop.
When you should not do this at all#
A full reset is a dev convenience. In production, "drop everything and start
over" is almost always the wrong instinct. The right move is usually a targeted
migration: ALTER TABLE, a backfill, a column drop behind a feature flag. If
you are resetting because a migration is too tangled to untangle, the fix is
to write smaller, reversible migrations going forward — not to make resetting
easier.
The RLS debugging guide covers the same instinct for policy drift: most "blow it away" urges are really "I cannot see what changed" problems.
TL;DR#
-- Dev reset, PostgreSQL 15+:
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO postgres;
GRANT USAGE, CREATE ON SCHEMA public TO app_role; -- re-grant your app role
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- restore extensions
CREATE EXTENSION IF NOT EXISTS pgcrypto;On Supabase dev: supabase db reset. On Supabase prod: restore from a backup,
do not drop the schema.
Further reading: Drizzle "table name specified more than once" in nested with.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Continue Reading
PostgreSQL INSERT IF NOT EXISTS: ON CONFLICT and NOT EXISTS
Stop getting 'duplicate key value violates unique constraint' errors in PostgreSQL. Use INSERT ... ON CONFLICT DO NOTHING with RETURNING to atomically insert only when the row is missing.
Drizzle "table name specified more than once" in nested with
Get a deep findMany({ with }) tree running again: how the 63-byte alias limit makes two Drizzle aliases identical, a pre-flight check, and two tested fixes.
Fix Drizzle Rollback: let the exception escape
Prove whether a swallowed manual rollback commits, then check the persisted rows after changing one catch handler.


