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
| Attribute | Grants |
|---|---|
LOGIN / NOLOGIN | May connect at all |
SUPERUSER | Bypasses every permission check, avoid |
CREATEDB | May create databases |
CREATEROLE | May create, alter, and drop non-superuser roles |
REPLICATION | May start replication and run pg_basebackup |
BYPASSRLS | Ignores row level security |
INHERIT / NOINHERIT | Automatically use granted roles' privileges, or need SET ROLE |
CONNECTION LIMIT n | Concurrent connections, -1 for unlimited |
VALID UNTIL ts | Password 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:
| Role | Grants |
|---|---|
pg_read_all_data | SELECT on every table, view, and sequence (14+) |
pg_write_all_data | INSERT, UPDATE, DELETE on everything (14+) |
pg_monitor | All the stats views, without superuser |
pg_read_all_stats / pg_read_all_settings | Narrower halves of pg_monitor |
pg_signal_backend | pg_cancel_backend and pg_terminate_backend on other roles |
pg_maintain | VACUUM, ANALYZE, REINDEX, CLUSTER on anything (17+) |
pg_checkpoint | Run CHECKPOINT |
GRANT and REVOKE on objects
Privileges are per object kind, and every one must be granted separately.
| Object | Privileges |
|---|---|
| Table, view | SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, MAINTAIN (17+) |
| Column | SELECT, INSERT, UPDATE, REFERENCES |
| Sequence | USAGE, SELECT, UPDATE |
| Schema | USAGE, CREATE |
| Database | CONNECT, CREATE, TEMPORARY |
| Function, procedure | EXECUTE |
| Type, domain | USAGE |
| Large object | SELECT, UPDATE |
| Parameter | SET, 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
| Clause | Applies to | Checks |
|---|---|---|
USING (expr) | SELECT, UPDATE, DELETE, ALL | Which existing rows are visible |
WITH CHECK (expr) | INSERT, UPDATE, ALL | Which new row values are allowed |
FOR ALL with only USING | Both | WITH 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
| Method | Means |
|---|---|
scram-sha-256 | Password over SCRAM, the correct default |
md5 | Legacy password hashing, migrate off it |
peer | Trust the OS user on a local socket, matched by name |
ident | Same idea over TCP via an ident server, weak |
trust | No check at all, local development only |
cert | TLS client certificate, CN must match the role |
reject | Refuse, useful as a final catch-all |
gss / ldap / radius / pam | External 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.