Skip to content
Postgres Access Privileges: Read \dp Output

Click to use (opens in a new tab)

Postgres Access Privileges: Read \dp Output

November 9, 2026 by Chat2DBChat2DB Team

\dp in psql answers the question "who can do what with this table", but its answer looks like this:

                                         Access privileges
 Schema |   Name   |   Type   |      Access privileges       |    Column privileges    | Policies
--------+----------+----------+------------------------------+-------------------------+----------
 app    | orders   | table    | app_owner=arwdxtm/app_owner +| id:                    +|
        |          |          | alice=ar/app_owner          +|   reporting=r/app_owner+|
        |          |          | Bob=r*/app_owner            +| total:                 +|
        |          |          | =r/app_owner                +|   reporting=r/app_owner+|
        |          |          | "José"=r/Bob                 | note:                  +|
        |          |          |                              |   reporting=w/app_owner |

Each line in the "Access privileges" column is one entry of the object's access control list (ACL), stored as the aclitem type. Once you know the format, it is quick to read. This guide decodes every part of it, explains the defaults and the blank cells, and shows SQL that answers the same questions in a form you can filter. All output in this article comes from PostgreSQL 18.6, with differences to 14 and 15 noted where they exist.

The Format: grantee=privileges/grantor

Every entry has three parts:

alice=ar/app_owner
│     │  └─ grantor: the role that granted these privileges
│     └──── privileges: one letter per privilege
└────────── grantee: the role that received them

So alice=ar/app_owner means "app_owner granted INSERT and SELECT to alice".

Three special cases:

  • Empty grantee means PUBLIC. =r/app_owner means app_owner granted SELECT to PUBLIC, the pseudo-role that every role is a member of. Anyone who can reach the table can read it.
  • A * after a letter is the grant option. Bob=r*/app_owner means Bob has SELECT and may grant SELECT to others. The entry "José"=r/Bob shows that he did.
  • Quoted names. Role names containing characters other than letters, digits and underscores are written in double quotes, as in "my user" or "José". A double quote inside a name is doubled. Uppercase names are not quoted in this output (Bob), but you still need quotes in SQL ("Bob"), because unquoted identifiers are folded to lowercase.

What the Letters Mean

LetterPrivilegeApplies to
aINSERTtables, columns
rSELECT ("read")tables, columns, sequences, large objects
wUPDATE ("write")tables, columns, sequences, large objects
dDELETEtables
DTRUNCATEtables
xREFERENCEStables, columns
tTRIGGERtables
mMAINTAIN (PostgreSQL 17+)tables
XEXECUTEfunctions, procedures
UUSAGEschemas, sequences, types, languages, foreign servers and wrappers
CCREATEdatabases, schemas, tablespaces
TTEMPORARYdatabases
cCONNECTdatabases
sSET (PostgreSQL 15+)configuration parameters
AALTER SYSTEM (PostgreSQL 15+)configuration parameters

The letters always appear in this fixed order, regardless of the order in which you granted them. A table owner on PostgreSQL 18 has arwdDxtm, all eight table privileges. On 14 to 16 the full set is arwdDxt, because MAINTAIN (which allows VACUUM, ANALYZE, REINDEX, REFRESH MATERIALIZED VIEW, CLUSTER and LOCK TABLE) did not exist yet.

Now the first entry of the example makes sense: app_owner=arwdxtm/app_owner is the owner with everything except D. Someone ran REVOKE TRUNCATE ON app.orders FROM app_owner. Owners can revoke privileges from themselves, and they can grant them back at any time, because ownership itself is not an entry in the list.

Column Privileges

The "Column privileges" column lists per-column grants, grouped by column name. In the example, reporting may read id and total and update note, but has no table-level entry. Only the four letters a, r, w and x can be granted on columns.

Column grants are checked together with table grants. A user with column-level SELECT on id and total can run SELECT id, total FROM app.orders, but SELECT * fails because it includes columns they cannot read. The column-level security guide goes deeper.

Blank, (none) and Defaults

An empty "Access privileges" cell does not mean nobody has access. It means the ACL is NULL, and PostgreSQL applies the built-in defaults: the owner has all privileges, and PUBLIC gets the defaults for that object type. For tables, views and sequences, PUBLIC gets nothing. You can see the defaults with acldefault:

SELECT acldefault('r', 'app_owner'::regrole) AS table_default,
       acldefault('f', 'app_owner'::regrole) AS function_default,
       acldefault('d', 'postgres'::regrole) AS database_default;
         table_default          |           function_default           |           database_default
--------------------------------+--------------------------------------+--------------------------------------
 {app_owner=arwdDxtm/app_owner} | {=X/app_owner,app_owner=X/app_owner} | {=Tc/postgres,postgres=CTc/postgres}

Two defaults surprise people:

  • Functions are executable by PUBLIC. =X/app_owner is there from the start. If a function should be private, REVOKE EXECUTE ON FUNCTION ... FROM PUBLIC explicitly.
  • Databases allow CONNECT and TEMPORARY for PUBLIC (=Tc/postgres). Any role can connect to any database unless you revoke it, which is why pg_hba.conf and REVOKE CONNECT ON DATABASE ... FROM PUBLIC matter.

The first GRANT or REVOKE on an object replaces the NULL with an explicit list that starts from these defaults. That is why a freshly created table shows a blank cell, and the same table shows owner=arwdDxtm/owner plus the new grantee after one GRANT.

If all privileges are revoked, even from the owner, the list exists but is empty: {} in the catalog. psql 17 and newer print this as (none). psql 16 and older print it as a blank cell, exactly like NULL, so on older clients the two look the same. Check the catalog to tell them apart:

SELECT relname, relacl FROM pg_class WHERE relname IN ('plain', 'emptyacl') ORDER BY relname;
 relname  | relacl
----------+--------
 emptyacl | {}
 plain    |

Databases and Schemas: \l and \dn+

The same format appears in \l (databases) and \dn+ (schemas):

 Name     | Owner    | ... |   Access privileges
----------+----------+-----+-----------------------
 postgres | postgres | ... | =Tc/postgres         +
          |          |     | postgres=CTc/postgres+
          |          |     | alice=Tc/postgres

=Tc/postgres is the PUBLIC default for databases, postgres=CTc/postgres is the owner, and alice=Tc/postgres was granted explicitly (and is redundant while PUBLIC still has the same rights).

For schemas, the public schema is a good example of a version difference. On PostgreSQL 15 and newer it is owned by pg_database_owner, and PUBLIC only has USAGE:

 public | pg_database_owner | pg_database_owner=UC/pg_database_owner+| standard public schema
        |                   | =U/pg_database_owner                   |

On PostgreSQL 14 and older, the same row shows =UC/postgres, which means every role could create objects in public. That change is the reason for the permission denied for schema public errors many teams hit after upgrading.

Default Privileges: \ddp

ALTER DEFAULT PRIVILEGES stores templates for objects created later. \ddp shows them in the same notation:

ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
  GRANT SELECT ON TABLES TO reporting;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner
  REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
               Default access privileges
   Owner   | Schema |   Type   |   Access privileges
-----------+--------+----------+-----------------------
 app_owner | app    | table    | reporting=r/app_owner
 app_owner |        | function | app_owner=X/app_owner

The second row reads "functions created by app_owner start with only the owner's EXECUTE", in other words PUBLIC is no longer included. Default privileges apply only to objects created afterwards, by the role named in the Owner column. A table created later by app_owner in app gets the extra entry automatically:

 app    | invoices | table | app_owner=arwdDxtm/app_owner+|                   |
        |          |       | reporting=r/app_owner        |                   |

Asking the Same Questions in SQL

The psql display is for people. For scripts and audits, use the catalog functions.

aclexplode turns an ACL into rows:

SELECT a.grantor::regrole,
       CASE a.grantee WHEN 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee,
       a.privilege_type, a.is_grantable
FROM pg_class c, aclexplode(c.relacl) AS a
WHERE c.oid = 'app.orders'::regclass
ORDER BY 2, 3;
  grantor  |  grantee  | privilege_type | is_grantable
-----------+-----------+----------------+--------------
 app_owner | "Bob"     | SELECT         | t
 "Bob"     | "José"    | SELECT         | f
 app_owner | PUBLIC    | SELECT         | f
 app_owner | alice     | INSERT         | f
 app_owner | alice     | SELECT         | f
 app_owner | app_owner | DELETE         | f
 app_owner | app_owner | INSERT         | f
 app_owner | app_owner | MAINTAIN       | f
 app_owner | app_owner | REFERENCES     | f
 app_owner | app_owner | SELECT         | f
 app_owner | app_owner | TRIGGER        | f
 app_owner | app_owner | UPDATE         | f
(12 rows)

The has_*_privilege functions answer "can this role do this", taking role membership and PUBLIC into account:

SELECT has_table_privilege('alice', 'app.orders', 'SELECT')  AS alice_select,
       has_table_privilege('alice', 'app.orders', 'DELETE')  AS alice_delete,
       has_column_privilege('reporting', 'app.orders', 'note', 'UPDATE') AS reporting_note;
 alice_select | alice_delete | reporting_note
--------------+--------------+----------------
 t            | f            | t

Pass role names as plain text, exactly as stored. has_table_privilege('"Bob"', ...) fails with role ""Bob"" does not exist, because the quotes become part of the name. Write 'Bob'.

The standard view information_schema.role_table_grants is another option, but it only shows standard SQL privileges. It leaves out MAINTAIN, and it only lists grants that involve roles you are a member of. Use the catalog for complete answers.

Decoding Entries Quickly

When you are reading many objects, or an ACL that someone pasted into a ticket, decoding by hand is slow and error-prone. Our free PostgreSQL Access Privileges Decoder (opens in a new tab) accepts \dp, \l, \dn+ and \ddp output or a raw relacl array, explains every entry in plain English, flags revoked owner or PUBLIC defaults, and writes the GRANT and REVOKE statements that recreate the ACL on another object. It runs entirely in the browser.

For day-to-day work, a GUI that shows grants per role is often faster than psql. In Chat2DB (opens in a new tab) you can inspect a table's privileges next to its structure and ask the AI assistant to write the GRANT statements for a new role.

Summary

  • Each ACL entry is grantee=privileges/grantor. An empty grantee is PUBLIC, and * marks the grant option.
  • Table letters are arwdDxt, plus m (MAINTAIN) on 17+. Other objects use X, U, C, T, c, and s/A for parameters on 15+.
  • A blank cell is the default ACL (owner has everything). (none) in psql 17+ means an empty ACL; older psql shows both as blank.
  • Functions and databases grant some privileges to PUBLIC by default.
  • \ddp shows default privileges for future objects.
  • Use aclexplode and has_*_privilege for scripts and audits.

To grant the privileges you have just learned to read, see GRANT ALL PRIVILEGES ON DATABASE and GRANT USAGE ON SCHEMA and SELECT on all tables.