SaaS architecture
must be owner of table in PostgreSQL: who may alter it?
What raised 'must be owner of table' in PostgreSQL 17, measured: which statements need ownership, which a grant covers, and how tables end up with mixed owners.
ERROR: must be owner of table invoices is what PostgreSQL says when a role tries to change a table’s definition and does not own it. No privilege on the table answers it: GRANT ALL leaves the error in place. In a team it tends to appear in a migration, on a table that someone once created from their own login, and the role that has run every migration for a year is told it does not own a table in its own schema.
We set this up on PostgreSQL 17.11 with a migration role, three developers with different kinds of membership, a schema owner and a database owner, and ran the statements a migration contains as each of them. Two things were not what the error suggests: some changes to a table are covered by a grant after all, and the owner of the schema can drop a table it cannot read.
What does “must be owner of table” mean in PostgreSQL?
The statement changes the definition of a table, and the role it runs as is neither the table’s owner, nor a member that inherits from the owner or has switched to it with SET ROLE, nor a superuser. It is SQLSTATE 42501, the same code as permission denied for table, but a different rule: permission denied is about a privilege that can be granted, ownership is not a privilege. A role with GRANT ALL on the table and CREATE on the schema ran these (in the messages below, o_mig is our migration role and the other o_ names are test roles):
| Statement | Result with ALL on the table |
|---|---|
create index on inv (tenant_id) | must be owner of table inv |
alter table inv add column …, alter column … set not null, add constraint … check, add foreign key | must be owner of table inv |
alter table inv rename to …, set schema …, set (fillfactor = 90), replica identity full, disable trigger all | must be owner of table inv |
alter table inv enable row level security, create policy … on inv | must be owner of table inv |
drop table inv, comment on table inv, create statistics … from inv, create rule … to inv | must be owner of table inv |
alter index inv_pkey rename to …, drop index inv_pkey | must be owner of index inv_pkey |
grant select on inv to … | WARNING: no privileges were granted for "inv"; the statement reports GRANT. A role holding SELECT … with grant option did pass it on |
create trigger … on inv | created (TRIGGER privilege) |
create table child (inv_id int references inv) | created (REFERENCES privilege) |
truncate inv | done (TRUNCATE privilege), before the foreign key above existed |
reindex table inv, cluster inv using inv_pkey, vacuum inv, analyze inv | done (MAINTAIN privilege, new in PostgreSQL 17; it also covers lock table in any mode) |
So ALL is not harmless short of ownership. With it, a role that cannot add a column can attach a trigger that runs on every insert, truncate the table, and create a foreign key that points at it, and each of those is a change other sessions feel. The owner feels it too. Once the other role’s child table existed, the owner’s truncate inv cascade failed with permission denied for table child, and once a role with nothing but CREATE on the schema had created a view over the table (which needs no privilege on the table; that is checked when the view is read), alter column id type bigint failed with cannot alter type of a column used by a view or rule. The GRANT in the seventh row is the quiet one: it did nothing and said GRANT.
Who else can alter a table besides its owner?
A role that inherits from the owner, a role that switches to the owner with SET ROLE, and a superuser. The owner of the schema, the owner of the database and a member of pg_maintain cannot alter it, though each can do something else to it:
| Role | create index on the migration role’s table |
|---|---|
| member of the migration role | created |
member granted with inherit false | must be owner of table inv |
the same member after set role to the migration role | created |
| owner of the schema | must be owner of table inv |
| owner of the database | permission denied for schema app; with USAGE on the schema, must be owner of table inv |
member of pg_maintain, with USAGE on the schema | must be owner of table inv; reindex, vacuum and lock table … in access exclusive mode worked |
role with CREATEROLE that did not create the migration role | permission denied for schema app; granting itself the migration role was refused too |
The schema owner is the odd one. It could not add a column to the table, and select count(*) on it failed with permission denied for table inv. It could drop it: drop table app.cust succeeded for the schema’s owner, and so did drop schema app cascade, which took four objects of other roles with it. Owning a schema gives the right to drop what is in it and to decide who may enter: its revoke usage on schema app from the migration role went through, and the migration role then got permission denied for schema app on its own tables. It does not give the right to read or alter the contents.
The database owner could not read, alter or drop the table either. Given USAGE on the schema it could run vacuum full on it, which rewrites the table under an exclusive lock, and analyze. “Owner of the database” reaches maintenance, not definitions or rows.
How do tables end up with different owners?
Through role membership. A member of the migration role can do everything the migration role can, so nobody notices that the tables they create are their own:
A developer creates a table in the schema from a psql session, without SET ROLE. The developer owns it, and reads it, alters it and indexes it without a hitch. The next deployment runs alter table by_alice add column y int as the migration role and gets must be owner of table by_alice. Membership works in one direction: the developer inherits from the migration role, the migration role inherits nothing from the developer.
The same table is also missing the application role’s grants, because default privileges set for the migration role do not apply to a table a member creates; that side is in ALTER DEFAULT PRIVILEGES not working.
To find them:
select n.nspname, c.relname, c.relkind, pg_get_userbyid(c.relowner) as owner
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'app'
and c.relkind in ('r', 'p', 'v', 'm', 'S', 'f')
and c.relowner <> 'migrator'::regrole
order by 1, 2;
Functions, types and domains are not in pg_class; they have owners too and raise the same kind of error.
How do you change the owner of a table?
alter table … owner to …, run by the current owner, who must also be allowed to become the new owner. We measured five cases:
Who ran alter table t owner to migrator | Result |
|---|---|
| the owner, a member of the migration role | changed |
the owner, a member granted with inherit false | changed |
| the owner, not a member of the migration role | must be able to SET ROLE "o_mig" |
the owner, a member granted with set false | must be able to SET ROLE "o_mig" |
| the migration role, not the owner | must be owner of table by_carol |
A developer who may not SET ROLE to the migration role cannot give a table away, and the migration role cannot take it: a superuser, or a role that is a member of both, has to do it. There is a third condition: the new owner needs CREATE on the schema. With that revoked from the migration role, the owner’s alter table … owner to failed with permission denied for schema app.
The grants travel with the change. A table that its creator had granted SELECT on to the application role showed {o_x=arwdDxtm/o_x,o_app=r/o_x} before and {o_mig=arwdDxtm/o_mig,o_app=r/o_mig} after: the application role kept its access, now granted by the new owner, and the previous owner, not a member of the new one, was refused on select with permission denied for table xt.
For everything a departed role owns, reassign owned by old to new does it in one statement per database. It needs the privileges of both roles. The migration role, not a member of the role that owned the tables, got permission denied to reassign objects with the detail Only roles with privileges of role "o_left" may reassign objects owned by it; a member of the old owner that was not a member of the new one got the same error with the detail Only roles with privileges of role "o_mig" may reassign objects to it. It moves ownership only. The old role’s default privileges and the grants it received stay, and drop role keeps failing until drop owned by, as measured in the article on default privileges.
How do you keep every table owned by the migration role?
One way is to make each developer’s session start as the migration role; the other is to keep developers out of that role altogether. alter role alice set role = 'migrator' runs SET ROLE at login: a new session of that developer reported current_user as the migration role and session_user as the developer, and the table it created was owned by the migration role. It worked for the member granted with inherit false as well. For a role that is not a member the login succeeded with WARNING: permission denied to set role "o_mig", and the session stayed itself.
It is a default, not a lock. reset role returns to it, but set role none put the session back to the developer, whose next table was the developer’s again, and a role may remove its own setting. It applies in every database unless written as alter role … in database … set role. It does make the usual path the right one, and session_user still records who it was.
It also makes every such session the table owner in every respect. On a table with row-level security and no FORCE, a member that inherits from the migration role counted all rows anyway, and a member granted with inherit false, which was refused as itself, counted all rows once the login default was set. The session could run alter table … no force row level security as well. Where developers should not read tenant data, the second way is the one: no membership, the pipeline logs in as the migration role and is the only thing that does, and people use a role of their own. That is the split in who bypasses row-level security.
What we did not measure
Ownership of functions, types, sequences on their own, and schemas. Partitions owned by a different role than their parent. A login default behind a connection pooler, where the pooler’s login role decides. Event triggers that refuse or correct a CREATE TABLE by the wrong role. Managed services, where the role you get as administrator is a member of your other roles in ways that differ per provider. Versions other than 17.11; MAINTAIN and pg_maintain do not exist before 17.
The messages around this one, and which privilege each statement needs, are in permission denied for table.
Sources: PostgreSQL 17 documentation, Privileges, and the PostgreSQL 17 release notes for MAINTAIN and pg_maintain, read on 13 October 2026.