PostgreSQL Cheatsheet

Roles and Permissions

Use this PostgreSQL reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Roles

There is one object, the role. CREATE USER is exactly CREATE ROLE ... LOGIN, and \du lists both. Roles are cluster-wide, not per database.

CREATE ROLE app_user LOGIN PASSWORD 'secret'
  CONNECTION LIMIT 20
  VALID UNTIL '2027-01-01';

CREATE ROLE readonly NOLOGIN;                  -- a group, nothing connects as it
CREATE ROLE app_admin LOGIN PASSWORD 'x' CREATEDB CREATEROLE;

ALTER ROLE app_user PASSWORD 'newsecret';
ALTER ROLE app_user WITH PASSWORD NULL;        -- remove the password, cannot log in
ALTER ROLE app_user RENAME TO api_user;        -- clears an md5 password, reset it
ALTER ROLE app_user SET statement_timeout = '10s';   -- per-role default GUC
DROP ROLE app_user;                            -- fails while it owns anything
AttributeGrants
LOGIN / NOLOGINMay connect at all
SUPERUSERBypasses every permission check, avoid
CREATEDBMay create databases
CREATEROLEMay create, alter, and drop non-superuser roles
REPLICATIONMay start replication and run pg_basebackup
BYPASSRLSIgnores row level security
INHERIT / NOINHERITAutomatically use granted roles' privileges, or need SET ROLE
CONNECTION LIMIT nConcurrent connections, -1 for unlimited
VALID UNTIL tsPassword expiry
SELECT rolname, rolsuper, rolcreatedb, rolcreaterole,
       rolcanlogin, rolreplication, rolbypassrls, rolconnlimit
FROM pg_roles ORDER BY rolname;

Before dropping a role, reassign what it owns:

REASSIGN OWNED BY old_role TO new_role;
DROP OWNED BY old_role;      -- drops the rest, and its privileges, per database
DROP ROLE old_role;

Group roles

Grant a role to a role to build a group. The whole point is that permissions live on group roles and login roles just inherit them.

GRANT readonly TO app_user;                       -- app_user gains readonly's rights
GRANT readonly TO lead WITH ADMIN OPTION;         -- lead may grant readonly onward
GRANT readonly TO app_user WITH INHERIT FALSE;    -- must SET ROLE to use it (16+)
REVOKE readonly FROM app_user;

SET ROLE readonly;      -- switch for this session
RESET ROLE;
SELECT current_user, session_user;   -- current_user changes, session_user does not

Postgres ships predefined roles you grant rather than write policies for:

RoleGrants
pg_read_all_dataSELECT on every table, view, and sequence (14+)
pg_write_all_dataINSERT, UPDATE, DELETE on everything (14+)
pg_monitorAll the stats views, without superuser
pg_read_all_stats / pg_read_all_settingsNarrower halves of pg_monitor
pg_signal_backendpg_cancel_backend and pg_terminate_backend on other roles
pg_maintainVACUUM, ANALYZE, REINDEX, CLUSTER on anything (17+)
pg_checkpointRun CHECKPOINT

GRANT and REVOKE on objects

Privileges are per object kind, and every one must be granted separately.

ObjectPrivileges
Table, viewSELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, MAINTAIN (17+)
ColumnSELECT, INSERT, UPDATE, REFERENCES
SequenceUSAGE, SELECT, UPDATE
SchemaUSAGE, CREATE
DatabaseCONNECT, CREATE, TEMPORARY
Function, procedureEXECUTE
Type, domainUSAGE
Large objectSELECT, UPDATE
ParameterSET, ALTER SYSTEM (15+)
GRANT SELECT, INSERT, UPDATE ON orders TO app_user;
GRANT ALL PRIVILEGES ON orders TO app_owner;
GRANT SELECT (id, total) ON orders TO analyst;          -- column level
GRANT USAGE, SELECT ON SEQUENCE orders_id_seq TO app_user;
GRANT USAGE ON SCHEMA app TO app_user;
GRANT CONNECT ON DATABASE prod TO app_user;
GRANT EXECUTE ON FUNCTION recalc(bigint) TO app_user;

GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly WITH GRANT OPTION;

REVOKE INSERT, UPDATE, DELETE ON orders FROM app_user;
REVOKE ALL ON SCHEMA public FROM PUBLIC;

ALL TABLES IN SCHEMA is a one-time operation over the tables that exist right now. Anything created afterwards is not covered, which is the single most common permission bug. That is what default privileges are for.

INSERT alone does not let a row use a serial column, because that needs USAGE on the sequence. An identity column does not, because it is owned by the table.

Note that a table owner keeps full rights even after a REVOKE, and a superuser ignores all of this.

Default privileges

ALTER DEFAULT PRIVILEGES sets what happens to objects created in the future by a given role in a given schema.

-- Everything I create in public from now on is readable by readonly
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO readonly;

ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT USAGE, SELECT ON SEQUENCES TO readonly;

-- Same, but for objects created by the migration role, not by me
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
  GRANT SELECT ON TABLES TO readonly;

ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
  REVOKE ALL ON FUNCTIONS FROM PUBLIC;

SELECT defaclrole::regrole AS for_role, defaclnamespace::regnamespace AS schema,
       defaclobjtype, defaclacl
FROM pg_default_acl;

The FOR ROLE part is the trap. Default privileges attach to the creating role, so a rule set while logged in as postgres does nothing for tables your migration tool creates as migrator.

From 15, PUBLIC no longer has CREATE on the public schema, so a fresh non-owner role cannot create tables there until granted. On 14 and earlier, revoke it yourself:

REVOKE CREATE ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON DATABASE prod FROM PUBLIC;   -- PUBLIC still gets CONNECT and TEMP by default

A read-only role, start to finish

CREATE ROLE readonly NOLOGIN;
CREATE ROLE analyst LOGIN PASSWORD 'x' IN ROLE readonly;

GRANT CONNECT ON DATABASE prod TO readonly;
GRANT USAGE ON SCHEMA public TO readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO readonly;

ALTER ROLE analyst SET statement_timeout = '60s';
ALTER ROLE analyst SET default_transaction_read_only = on;

On 14+ the whole middle is one line, and it stays correct for future tables:

GRANT pg_read_all_data TO readonly;

Verify rather than assume:

SELECT has_table_privilege('analyst', 'orders', 'SELECT')  AS can_read,
       has_table_privilege('analyst', 'orders', 'INSERT')  AS can_write,
       has_schema_privilege('analyst', 'public', 'USAGE')  AS can_use_schema;

SELECT grantee, privilege_type
FROM information_schema.table_privileges
WHERE table_schema = 'public' AND table_name = 'orders';

\dp orders and \z orders print the same ACL compactly in psql.

SECURITY DEFINER functions

A SECURITY DEFINER function runs with the owner's privileges, which is how you let a limited role do one privileged thing without granting it broadly.

CREATE FUNCTION archive_old_orders() RETURNS bigint
LANGUAGE sql
SECURITY DEFINER
SET search_path = pg_catalog, pg_temp
AS $$
  WITH moved AS (
    DELETE FROM public.orders WHERE placed_at < now() - interval '2 years'
    RETURNING *
  )
  INSERT INTO public.orders_archive SELECT * FROM moved RETURNING 1;
$$;

REVOKE EXECUTE ON FUNCTION archive_old_orders() FROM PUBLIC;
GRANT  EXECUTE ON FUNCTION archive_old_orders() TO app_user;

Three rules, all of them security relevant:

- Always pin search_path. Without it a caller can create a pg_temp function or a schema earlier in the path that shadows one you call, and run their code as the owner. - With a pinned search_path, schema-qualify every object inside the body, or you get ERROR: relation "orders" does not exist. - Functions are EXECUTE-able by PUBLIC by default, so revoke first, then grant.

SECURITY INVOKER is the default and needs none of this. Note that a SECURITY DEFINER function does not escape row level security unless the owner has BYPASSRLS or the table is not forced.

Row level security

RLS filters rows per role, enforced by the server rather than by your WHERE clauses. It is off until enabled, and once enabled with no policy the table returns nothing.

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;   -- apply to the owner too
ClauseApplies toChecks
USING (expr)SELECT, UPDATE, DELETE, ALLWhich existing rows are visible
WITH CHECK (expr)INSERT, UPDATE, ALLWhich new row values are allowed
FOR ALL with only USINGBothWITH CHECK defaults to the USING expression
AS PERMISSIVE (default)—Policies combine with OR
AS RESTRICTIVE—ANDed on top, use it to add a hard limit
-- Multi-tenant isolation driven by a session variable
CREATE POLICY tenant_isolation ON documents
  FOR ALL
  USING      (tenant_id = current_setting('app.tenant_id', true)::bigint)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::bigint);

-- Owners write, everyone in the tenant reads
CREATE POLICY doc_read  ON documents FOR SELECT TO app_user USING (true);
CREATE POLICY doc_write ON documents FOR UPDATE TO app_user
  USING (owner_id = current_setting('app.user_id', true)::bigint);

-- A hard floor no permissive policy can widen
CREATE POLICY no_secrets ON documents AS RESTRICTIVE FOR SELECT
  USING (classification <> 'secret');

The application sets the variable once per connection checkout, inside the transaction so a pooled connection cannot leak it:

BEGIN;
SELECT set_config('app.tenant_id', $1, true);   -- true = local to this transaction
SELECT * FROM documents;                        -- policy applies automatically
COMMIT;
-- Inspect what is in force
SELECT polname, polcmd, polpermissive,
       pg_get_expr(polqual, polrelid)      AS using_expr,
       pg_get_expr(polwithcheck, polrelid) AS check_expr
FROM pg_policy WHERE polrelid = 'documents'::regclass;

The gotchas: the table owner bypasses RLS unless you add FORCE, BYPASSRLS roles ignore it entirely, and a policy expression is evaluated per row, so make sure it can use an index. Pass the tenant through current_setting, never string-interpolate it.

pg_hba.conf and connections

pg_hba.conf decides who may connect, from where, to what, and how. Rules are read top to bottom and the first match wins, so order matters more than specificity.

# TYPE  DATABASE  USER      ADDRESS          METHOD
local   all       postgres                   peer
local   all       all                        scram-sha-256
host    prod      app_user  10.0.0.0/8       scram-sha-256
hostssl prod      analyst   0.0.0.0/0        scram-sha-256  clientcert=verify-full
host    replication repl    10.0.0.5/32      scram-sha-256
host    all       all       0.0.0.0/0        reject
MethodMeans
scram-sha-256Password over SCRAM, the correct default
md5Legacy password hashing, migrate off it
peerTrust the OS user on a local socket, matched by name
identSame idea over TCP via an ident server, weak
trustNo check at all, local development only
certTLS client certificate, CN must match the role
rejectRefuse, useful as a final catch-all
gss / ldap / radius / pamExternal directories

hostssl requires TLS, hostnossl requires its absence, and host accepts either. Reload after editing, no restart needed:

SELECT pg_reload_conf();
SELECT type, database, user_name, address, auth_method, error
FROM pg_hba_file_rules ORDER BY rule_number;   -- shows parse errors too
SELECT name, setting FROM pg_settings
WHERE name IN ('hba_file', 'config_file', 'password_encryption', 'ssl');

Client-side credentials, in order of preference:

# A connection URI, and sslmode is the flag that matters
psql "postgres://app_user@db.example.com:5432/prod?sslmode=verify-full"

# ~/.pgpass, mode 0600, one line per target
# hostname:port:database:username:password
echo 'db.example.com:5432:prod:app_user:secret' >> ~/.pgpass
chmod 600 ~/.pgpass

Prefer ~/.pgpass or a service file over PGPASSWORD, which leaks into the process list and shell history. sslmode=require encrypts but verifies nothing, so use verify-full with a sslrootcert in production.