Moving a Table to Another Schema
The billing team is preparing to extract its tables into a separate service and database. Step one is to gather invoices, invoice_lines and payments — currently mixed into the public schema with everything else — into a billing schema, so ownership, permissions and eventually replication can be managed as a unit. ALTER TABLE invoices SET SCHEMA billing is instant. It also immediately breaks every query that says public.invoices explicitly, and every query that says invoices from a role whose search_path does not include billing. This guide moves tables between schemas online, choosing the right bridge for how your code refers to tables, and handles the permission and dependency details that make moves fail. It belongs to Renaming and Splitting Tables Online.
Symptom / Error Signatures
A move without a bridge fails like a rename:
ERROR: relation "public.invoices" does not exist
ERROR: relation "invoices" does not exist -- unqualified, search_path lacks the new schema
ERROR: permission denied for schema billing -- role lacks USAGE on the new schema
The last one is easy to miss in testing with a superuser: tables keep their own grants when moved, but a role also needs USAGE on the schema that contains them.
Root Cause Analysis
ALTER TABLE ... SET SCHEMA changes only the table’s namespace in the catalog. It takes ACCESS EXCLUSIVE briefly, moves the table’s indexes, constraints and owned sequences along with it, and keeps the table’s privileges. Internal references — views, foreign keys, triggers — follow the table by OID and keep working. What changes is name resolution for queries written as text:
| Query form | After the move | Bridge |
|---|---|---|
FROM invoices, role search_path = public |
fails | add billing to the role’s search_path |
FROM invoices, search_path = "$user", public, billing |
works | none needed |
FROM public.invoices |
fails | view public.invoices over billing.invoices |
FROM billing.invoices (new code) |
works | — |
search_path changes affect only new sessions (or sessions that run SET search_path), so for pooled connections the change must be in place — and the pool recycled — before the move. Views are resolved at query time and take effect immediately, and simple views are automatically updatable, as described in renaming a table with an updatable view.
Immediate Mitigation
If a move already broke queries, restore the old name immediately with a view (for qualified queries) or by adding the schema to the search path (for unqualified ones):
-- PostgreSQL · migration role · restores public.invoices as an updatable view
SET lock_timeout = '3s';
CREATE VIEW public.invoices AS SELECT * FROM billing.invoices;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.invoices TO app;
GRANT USAGE ON SCHEMA billing TO app;
-- ROLLBACK PATH: DROP VIEW public.invoices;
-- PostgreSQL · superuser or role owner · affects new sessions of the role; recycle pools afterwards
ALTER ROLE app SET search_path = "$user", public, billing;
Permanent Fix / Long-Term Pattern
1. Prepare the schema and privileges.
-- PostgreSQL · superuser or database owner
CREATE SCHEMA IF NOT EXISTS billing AUTHORIZATION billing_owner;
GRANT USAGE ON SCHEMA billing TO app, reporting;
ALTER DEFAULT PRIVILEGES IN SCHEMA billing GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;
2. Extend search_path for roles using unqualified names, then recycle connection pools so every session picks it up. Verify with SHOW search_path from an application session.
3. Move the table group in one transaction, with views for qualified names. Moving related tables together keeps foreign keys and joins within one move.
-- PostgreSQL · migration role · brief ACCESS EXCLUSIVE on each table
-- WARNING: include views for every table that code references as public.<table>.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE public.invoices SET SCHEMA billing;
ALTER TABLE public.invoice_lines SET SCHEMA billing;
ALTER TABLE public.payments SET SCHEMA billing;
CREATE VIEW public.invoices AS SELECT * FROM billing.invoices;
CREATE VIEW public.invoice_lines AS SELECT * FROM billing.invoice_lines;
CREATE VIEW public.payments AS SELECT * FROM billing.payments;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.invoices, public.invoice_lines, public.payments TO app;
COMMIT;
-- ROLLBACK PATH: drop the three views and SET SCHEMA public for the three tables in one transaction.
Note a subtlety: with both the view public.invoices and the table billing.invoices present, an unqualified invoices resolves to whichever schema comes first in search_path. With public first it resolves to the view, which is updatable and therefore works — but for clarity, move code to explicit billing. names.
4. Update code to qualified billing.* names service by service, and confirm through query statistics that nothing uses the public names any more, as in Renaming and Splitting Tables Online.
5. Remove the bridges — drop the views and remove billing from search_path if it is no longer needed. Once the schema is self-contained, extracting it to its own database becomes a replication task, discussed in dual-writing across two databases during a cutover.
Two more dependencies deserve a check before the move. Functions and procedures written in SQL or PL/pgSQL store their bodies as text and resolve table names when they run, using the function’s own search_path setting if it has one; a function declared with SET search_path = public will stop finding the moved table. List such functions with SELECT proname, proconfig FROM pg_proc WHERE proconfig IS NOT NULL and update their settings or bodies. And logical replication publications or CDC connectors that list tables by qualified name need their table lists updated, or they will silently stop streaming the moved tables. Backup and restore tooling that selects tables by schema (for example pg_dump --schema=public for a partial dump) needs the same update, so that the moved tables are still captured by whatever job used to include them.
Verification Checklist
Frequently Asked Questions
Does SET SCHEMA copy the table?
No. It changes the table’s namespace in the catalog. Data, indexes, constraints and owned sequences stay where they are and move with it logically.
Do foreign keys between moved and unmoved tables still work? Yes. Foreign keys reference tables by OID, not by name, so they are unaffected by the move.
Why do I need to recycle connection pools after changing search_path?
ALTER ROLE ... SET search_path applies to new sessions. Pooled connections opened earlier keep the old value until they reconnect.
Can I move a table between databases the same way? No. Databases do not share catalogs, so moving between them means copying data and keeping it in sync — logical replication or CDC — and then cutting over, which is a separate procedure.