SQL


https://www.postgresql.org/docs/current/sql-commands.html
https://www.postgresql.org/docs/current/queries.html
https://www.postgresql.org/docs/current/ddl-schemas.html
https://www.postgresql.org/docs/current/catalog-pg-namespace.html
https://www.postgresql.org/docs/current/infoschema-schemata.html
https://www.postgresql.org/docs/current/functions-info.html
https://www.postgresql.org/docs/current/monitoring-stats.html

1. Important Points#

scope:
    this page focuses on common SQL used by developers / DBA / SRE
    psql meta commands such as \l / \dt are useful, but they are not SQL
    examples use database orders and schema app

safe habit:
    always qualify production objects with schema name:
        app.orders

    use transaction for multi-step DML:
        BEGIN;
        ...
        COMMIT;

    preview before destructive SQL:
        SELECT ... WHERE ...
        then UPDATE / DELETE with the same WHERE

    avoid in production without review:
        DROP ... CASCADE
        TRUNCATE
        UPDATE without WHERE
        DELETE without WHERE
        long transaction
common naming:
    database:
        orders

    schema / namespace:
        app

    table:
        app.orders
        app.order_items

    roles:
        dba_order
        order_migrator
        order_app
        order_readonly

2. Database#

show current database#

SELECT current_database();

list databases#

SELECT
  datname,
  pg_get_userbyid(datdba) AS owner,
  pg_encoding_to_char(encoding) AS encoding,
  datcollate,
  datctype,
  datallowconn,
  datconnlimit
FROM pg_database
ORDER BY datname;

create database#

CREATE DATABASE must run from another database, usually postgres, not from the target database.

CREATE DATABASE orders
  OWNER dba_order
  TEMPLATE template0
  ENCODING 'UTF8'
  LC_COLLATE 'C'
  LC_CTYPE 'C'
  TABLESPACE pg_default
  CONNECTION LIMIT 200;

create database if not exists#

PostgreSQL has no native CREATE DATABASE IF NOT EXISTS.

SELECT 1
FROM pg_database
WHERE datname = 'orders';

If no row is returned, run:

CREATE DATABASE orders OWNER dba_order;

alter database#

ALTER DATABASE orders OWNER TO dba_order;

ALTER DATABASE orders
  CONNECTION LIMIT 300;

ALTER DATABASE orders
  SET search_path TO app, public;

ALTER DATABASE orders
  RESET search_path;

rename database#

ALTER DATABASE orders RENAME TO orders_old;

drop database#

DROP DATABASE orders;

Safer pattern when active connections exist:

SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'orders'
  AND pid <> pg_backend_pid();

DROP DATABASE orders;

PostgreSQL also supports:

DROP DATABASE orders WITH (FORCE);
warning:
    DROP DATABASE removes the whole database
    run from another database
    cannot be rolled back after commit

3. Schema / Namespace#

PostgreSQL catalog uses namespace for schema-like object namespace. In daily SQL, users usually say schema.

current schema and search path#

SELECT current_schema();

SHOW search_path;

SELECT current_schemas(true);

query namespace from information_schema#

SELECT
  schema_name,
  schema_owner
FROM information_schema.schemata
ORDER BY schema_name;

query namespace from pg_namespace#

SELECT
  n.oid,
  n.nspname AS schema_name,
  r.rolname AS owner
FROM pg_namespace n
JOIN pg_roles r ON r.oid = n.nspowner
WHERE n.nspname NOT LIKE 'pg_%'
  AND n.nspname <> 'information_schema'
ORDER BY n.nspname;

create schema#

CREATE SCHEMA app AUTHORIZATION order_migrator;

Create schema and objects together:

CREATE SCHEMA app AUTHORIZATION order_migrator
  CREATE TABLE orders (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_no text NOT NULL UNIQUE,
    created_at timestamptz NOT NULL DEFAULT now()
  );

create schema if not exists#

CREATE SCHEMA IF NOT EXISTS app AUTHORIZATION order_migrator;

alter schema#

ALTER SCHEMA app OWNER TO order_migrator;

ALTER SCHEMA app RENAME TO app_old;

drop schema#

DROP SCHEMA app;

Drop only if it exists:

DROP SCHEMA IF EXISTS app;

Drop schema and all objects inside:

DROP SCHEMA IF EXISTS app CASCADE;
production rule:
    use RESTRICT/default first
    only use CASCADE after listing dependent objects

list objects in schema#

SELECT
  table_schema,
  table_name,
  table_type
FROM information_schema.tables
WHERE table_schema = 'app'
ORDER BY table_name;
SELECT
  schemaname,
  tablename,
  tableowner
FROM pg_tables
WHERE schemaname = 'app'
ORDER BY tablename;

4. Table DDL#

create table#

CREATE TABLE app.orders (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_no text NOT NULL UNIQUE,
  customer_id bigint NOT NULL,
  status text NOT NULL DEFAULT 'created',
  amount numeric(12, 2) NOT NULL CHECK (amount >= 0),
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

create table if not exists#

CREATE TABLE IF NOT EXISTS app.order_items (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id bigint NOT NULL REFERENCES app.orders(id) ON DELETE CASCADE,
  sku text NOT NULL,
  quantity integer NOT NULL CHECK (quantity > 0),
  unit_price numeric(12, 2) NOT NULL CHECK (unit_price >= 0),
  created_at timestamptz NOT NULL DEFAULT now()
);

create table from query#

CREATE TABLE app.big_orders AS
SELECT *
FROM app.orders
WHERE amount >= 1000;

create temporary table#

CREATE TEMP TABLE tmp_order_ids (
  order_id bigint PRIMARY KEY
) ON COMMIT DROP;

unlogged table#

CREATE UNLOGGED TABLE app.import_orders (
  order_no text,
  payload jsonb,
  imported_at timestamptz DEFAULT now()
);
unlogged table:
    faster writes
    not WAL-logged
    not crash-safe like regular table
    usually for staging / import / cache

comments#

COMMENT ON TABLE app.orders IS 'Business orders';
COMMENT ON COLUMN app.orders.order_no IS 'External order number';

add column#

ALTER TABLE app.orders
  ADD COLUMN paid_at timestamptz;

add column with default#

ALTER TABLE app.orders
  ADD COLUMN source text NOT NULL DEFAULT 'web';

rename column#

ALTER TABLE app.orders
  RENAME COLUMN source TO channel;

alter column type#

ALTER TABLE app.orders
  ALTER COLUMN amount TYPE numeric(14, 2);

With conversion:

ALTER TABLE app.orders
  ALTER COLUMN order_no TYPE text USING order_no::text;

set / drop default#

ALTER TABLE app.orders
  ALTER COLUMN status SET DEFAULT 'created';

ALTER TABLE app.orders
  ALTER COLUMN status DROP DEFAULT;

set / drop not null#

ALTER TABLE app.orders
  ALTER COLUMN customer_id SET NOT NULL;

ALTER TABLE app.orders
  ALTER COLUMN paid_at DROP NOT NULL;

add constraints#

ALTER TABLE app.orders
  ADD CONSTRAINT orders_status_check
  CHECK (status IN ('created', 'paid', 'cancelled'));
ALTER TABLE app.order_items
  ADD CONSTRAINT order_items_order_id_fkey
  FOREIGN KEY (order_id)
  REFERENCES app.orders(id)
  ON DELETE CASCADE;

validate constraint later#

Useful for large existing tables.

ALTER TABLE app.orders
  ADD CONSTRAINT orders_status_check
  CHECK (status IN ('created', 'paid', 'cancelled')) NOT VALID;

ALTER TABLE app.orders
  VALIDATE CONSTRAINT orders_status_check;

drop constraint#

ALTER TABLE app.orders
  DROP CONSTRAINT IF EXISTS orders_status_check;

rename table#

ALTER TABLE app.orders
  RENAME TO orders_old;

move table to another schema#

ALTER TABLE app.orders
  SET SCHEMA archive;

truncate table#

TRUNCATE TABLE app.order_items;

With identity reset:

TRUNCATE TABLE app.order_items RESTART IDENTITY;

With child/dependent tables:

TRUNCATE TABLE app.orders CASCADE;

drop table#

DROP TABLE app.order_items;

DROP TABLE IF EXISTS app.order_items;

Drop with dependent objects:

DROP TABLE IF EXISTS app.orders CASCADE;
drop vs truncate vs delete:
    DELETE:
        removes selected rows
        can use WHERE
        fires row-level delete triggers

    TRUNCATE:
        removes all rows quickly
        no WHERE
        stronger lock

    DROP:
        removes table definition and data

5. Index#

create index#

CREATE INDEX orders_customer_id_idx
ON app.orders (customer_id);

unique index#

CREATE UNIQUE INDEX orders_order_no_uidx
ON app.orders (order_no);

composite index#

CREATE INDEX orders_customer_status_created_idx
ON app.orders (customer_id, status, created_at DESC);
index order:
    equality columns first
    then range/sort columns
    match common query patterns

partial index#

CREATE INDEX orders_unpaid_idx
ON app.orders (created_at)
WHERE status = 'created';

expression index#

CREATE INDEX orders_lower_order_no_idx
ON app.orders (lower(order_no));

jsonb index#

CREATE INDEX orders_metadata_gin_idx
ON app.orders USING gin (metadata);

create index concurrently#

CREATE INDEX CONCURRENTLY orders_created_at_idx
ON app.orders (created_at);
note:
    CREATE INDEX CONCURRENTLY reduces write blocking
    it cannot run inside a transaction block

drop index#

DROP INDEX app.orders_created_at_idx;

DROP INDEX CONCURRENTLY IF EXISTS app.orders_created_at_idx;

reindex#

REINDEX INDEX app.orders_customer_id_idx;

REINDEX TABLE app.orders;

REINDEX DATABASE orders;

6. Query#

basic select#

SELECT
  id,
  order_no,
  status,
  amount,
  created_at
FROM app.orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 50;

filter#

SELECT *
FROM app.orders
WHERE customer_id = 10001
  AND status IN ('created', 'paid')
  AND amount >= 100
  AND created_at >= now() - interval '7 days';

null#

SELECT *
FROM app.orders
WHERE paid_at IS NULL;

SELECT *
FROM app.orders
WHERE paid_at IS NOT NULL;

like / ilike#

SELECT *
FROM app.orders
WHERE order_no LIKE 'ORD-%';

SELECT *
FROM app.orders
WHERE order_no ILIKE 'ord-%';

join#

SELECT
  o.id,
  o.order_no,
  o.status,
  i.sku,
  i.quantity
FROM app.orders o
JOIN app.order_items i ON i.order_id = o.id
WHERE o.id = 1001;

left join#

SELECT
  o.id,
  o.order_no,
  count(i.id) AS item_count
FROM app.orders o
LEFT JOIN app.order_items i ON i.order_id = o.id
GROUP BY o.id, o.order_no;

aggregate#

SELECT
  status,
  count(*) AS order_count,
  sum(amount) AS total_amount,
  avg(amount) AS avg_amount
FROM app.orders
GROUP BY status
HAVING count(*) > 10
ORDER BY total_amount DESC;

distinct#

SELECT DISTINCT status
FROM app.orders
ORDER BY status;

distinct on#

Get latest order per customer:

SELECT DISTINCT ON (customer_id)
  customer_id,
  id,
  order_no,
  created_at
FROM app.orders
ORDER BY customer_id, created_at DESC;

common table expression#

WITH recent_orders AS (
  SELECT *
  FROM app.orders
  WHERE created_at >= now() - interval '30 days'
)
SELECT status, count(*)
FROM recent_orders
GROUP BY status;

recursive CTE#

WITH RECURSIVE category_tree AS (
  SELECT id, parent_id, name, 1 AS depth
  FROM app.categories
  WHERE parent_id IS NULL

  UNION ALL

  SELECT c.id, c.parent_id, c.name, t.depth + 1
  FROM app.categories c
  JOIN category_tree t ON t.id = c.parent_id
)
SELECT *
FROM category_tree
ORDER BY depth, id;

window function#

SELECT
  customer_id,
  order_no,
  amount,
  created_at,
  row_number() OVER (
    PARTITION BY customer_id
    ORDER BY created_at DESC
  ) AS rn
FROM app.orders;

pagination#

Offset pagination:

SELECT *
FROM app.orders
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 100;

Keyset pagination:

SELECT *
FROM app.orders
WHERE (created_at, id) < (timestamp '2026-01-01 00:00:00+00', 1000)
ORDER BY created_at DESC, id DESC
LIMIT 50;
production preference:
    use keyset pagination for deep pages
    offset pagination gets slower as offset grows

jsonb query#

SELECT *
FROM app.orders
WHERE metadata @> '{"channel": "web"}'::jsonb;
SELECT
  id,
  metadata->>'channel' AS channel,
  metadata->'payment'->>'method' AS payment_method
FROM app.orders;

array query#

SELECT *
FROM app.products
WHERE tags @> ARRAY['featured'];

date / time query#

SELECT *
FROM app.orders
WHERE created_at >= date_trunc('day', now())
  AND created_at < date_trunc('day', now()) + interval '1 day';

exists#

SELECT *
FROM app.orders o
WHERE EXISTS (
  SELECT 1
  FROM app.order_items i
  WHERE i.order_id = o.id
);

7. Insert / Update / Delete#

insert#

INSERT INTO app.orders (
  order_no,
  customer_id,
  status,
  amount,
  metadata
) VALUES (
  'ORD-1001',
  10001,
  'created',
  99.90,
  '{"channel": "web"}'::jsonb
);

insert returning#

INSERT INTO app.orders (order_no, customer_id, amount)
VALUES ('ORD-1002', 10001, 199.00)
RETURNING id, order_no, created_at;

insert multiple rows#

INSERT INTO app.order_items (order_id, sku, quantity, unit_price)
VALUES
  (1001, 'SKU-001', 2, 10.00),
  (1001, 'SKU-002', 1, 79.90);

update#

UPDATE app.orders
SET
  status = 'paid',
  paid_at = now(),
  updated_at = now()
WHERE id = 1001
  AND status = 'created'
RETURNING id, status, paid_at;

update from join#

UPDATE app.orders o
SET
  amount = s.amount,
  updated_at = now()
FROM app.order_amount_staging s
WHERE s.order_no = o.order_no;

delete#

DELETE FROM app.orders
WHERE id = 1001
RETURNING id, order_no;

delete using join#

DELETE FROM app.order_items i
USING app.orders o
WHERE o.id = i.order_id
  AND o.status = 'cancelled';

upsert#

INSERT INTO app.orders (order_no, customer_id, status, amount)
VALUES ('ORD-1001', 10001, 'created', 99.90)
ON CONFLICT (order_no)
DO UPDATE SET
  status = EXCLUDED.status,
  amount = EXCLUDED.amount,
  updated_at = now()
RETURNING id, order_no, status;

Ignore duplicates:

INSERT INTO app.orders (order_no, customer_id, amount)
VALUES ('ORD-1001', 10001, 99.90)
ON CONFLICT (order_no) DO NOTHING;

merge#

MERGE INTO app.orders o
USING app.order_imports s
ON o.order_no = s.order_no
WHEN MATCHED THEN
  UPDATE SET amount = s.amount, updated_at = now()
WHEN NOT MATCHED THEN
  INSERT (order_no, customer_id, amount)
  VALUES (s.order_no, s.customer_id, s.amount);

copy#

Server-side file path:

COPY app.orders (order_no, customer_id, status, amount)
FROM '/var/lib/postgresql/import/orders.csv'
WITH (FORMAT csv, HEADER true);

Client-side import with psql:

\copy app.orders (order_no, customer_id, status, amount) FROM './orders.csv' WITH (FORMAT csv, HEADER true)

8. Transaction / Lock#

transaction#

BEGIN;

UPDATE app.orders
SET status = 'paid', updated_at = now()
WHERE id = 1001
  AND status = 'created';

INSERT INTO app.order_events (order_id, event_type, created_at)
VALUES (1001, 'paid', now());

COMMIT;

rollback#

BEGIN;

UPDATE app.orders
SET status = 'cancelled'
WHERE id = 1001;

ROLLBACK;

savepoint#

BEGIN;

SAVEPOINT before_item_update;

UPDATE app.order_items
SET quantity = 2
WHERE id = 5001;

ROLLBACK TO SAVEPOINT before_item_update;

COMMIT;

isolation level#

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

SELECT *
FROM app.orders
WHERE customer_id = 10001;

COMMIT;

lock rows for update#

SELECT *
FROM app.orders
WHERE id = 1001
FOR UPDATE;

Skip locked for worker jobs:

WITH picked AS (
  SELECT id
  FROM app.jobs
  WHERE status = 'ready'
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 10
)
UPDATE app.jobs j
SET status = 'running', updated_at = now()
FROM picked
WHERE j.id = picked.id
RETURNING j.*;

advisory lock#

SELECT pg_try_advisory_lock(1001);

SELECT pg_advisory_unlock(1001);

9. Role / Privilege#

Detailed role model is in PostgreSQL Users. This section keeps the SQL most often used with database/schema/table work.

create role#

CREATE ROLE dba_order NOLOGIN;

CREATE ROLE order_migrator LOGIN PASSWORD 'change-me';

CREATE ROLE order_app LOGIN PASSWORD 'change-me';

CREATE ROLE order_readonly LOGIN PASSWORD 'change-me';

alter role#

ALTER ROLE order_app PASSWORD 'new-password';

ALTER ROLE order_app SET search_path TO app, public;

ALTER ROLE order_app CONNECTION LIMIT 100;

grant database access#

GRANT CONNECT ON DATABASE orders TO order_app;
GRANT CONNECT ON DATABASE orders TO order_readonly;

grant schema access#

GRANT USAGE ON SCHEMA app TO order_app;
GRANT USAGE ON SCHEMA app TO order_readonly;

GRANT CREATE ON SCHEMA app TO order_migrator;

grant table access#

GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA app
TO order_app;

GRANT SELECT
ON ALL TABLES IN SCHEMA app
TO order_readonly;

grant sequence access#

GRANT USAGE, SELECT
ON ALL SEQUENCES IN SCHEMA app
TO order_app;

default privileges#

ALTER DEFAULT PRIVILEGES
FOR ROLE order_migrator
IN SCHEMA app
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO order_app;

ALTER DEFAULT PRIVILEGES
FOR ROLE order_migrator
IN SCHEMA app
GRANT SELECT ON TABLES TO order_readonly;

ALTER DEFAULT PRIVILEGES
FOR ROLE order_migrator
IN SCHEMA app
GRANT USAGE, SELECT ON SEQUENCES TO order_app;

revoke#

REVOKE CREATE ON SCHEMA public FROM PUBLIC;

REVOKE ALL ON DATABASE orders FROM PUBLIC;

REVOKE SELECT ON app.orders FROM order_readonly;

check privileges#

SELECT has_database_privilege('order_app', 'orders', 'CONNECT');

SELECT has_schema_privilege('order_app', 'app', 'USAGE');

SELECT has_table_privilege('order_app', 'app.orders', 'SELECT');

10. View / Materialized View#

create view#

CREATE OR REPLACE VIEW app.paid_orders AS
SELECT
  id,
  order_no,
  customer_id,
  amount,
  paid_at
FROM app.orders
WHERE status = 'paid';

drop view#

DROP VIEW IF EXISTS app.paid_orders;

create materialized view#

CREATE MATERIALIZED VIEW app.daily_order_summary AS
SELECT
  date_trunc('day', created_at)::date AS order_day,
  status,
  count(*) AS order_count,
  sum(amount) AS total_amount
FROM app.orders
GROUP BY 1, 2;

refresh materialized view#

REFRESH MATERIALIZED VIEW app.daily_order_summary;

Refresh without blocking readers requires a unique index:

CREATE UNIQUE INDEX daily_order_summary_uidx
ON app.daily_order_summary (order_day, status);

REFRESH MATERIALIZED VIEW CONCURRENTLY app.daily_order_summary;

11. Sequence / Identity#

create sequence#

CREATE SEQUENCE app.order_no_seq
  START WITH 1000
  INCREMENT BY 1;

next value#

SELECT nextval('app.order_no_seq');

current value#

SELECT currval('app.order_no_seq');

set sequence value#

SELECT setval('app.order_no_seq', 5000, true);

restart identity#

ALTER TABLE app.orders
  ALTER COLUMN id RESTART WITH 100000;

12. Partition#

range partition table#

CREATE TABLE app.order_events (
  id bigint GENERATED ALWAYS AS IDENTITY,
  order_id bigint NOT NULL,
  event_type text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

create partition#

CREATE TABLE app.order_events_2026_01
PARTITION OF app.order_events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

detach partition#

ALTER TABLE app.order_events
DETACH PARTITION app.order_events_2026_01;

drop old partition#

DROP TABLE app.order_events_2026_01;

13. Function / Trigger#

update timestamp function#

CREATE OR REPLACE FUNCTION app.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

trigger#

CREATE TRIGGER orders_set_updated_at
BEFORE UPDATE ON app.orders
FOR EACH ROW
EXECUTE FUNCTION app.set_updated_at();

drop trigger / function#

DROP TRIGGER IF EXISTS orders_set_updated_at ON app.orders;

DROP FUNCTION IF EXISTS app.set_updated_at();

14. Explain / Maintenance#

explain#

EXPLAIN
SELECT *
FROM app.orders
WHERE customer_id = 10001
ORDER BY created_at DESC
LIMIT 50;

explain analyze#

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM app.orders
WHERE customer_id = 10001
ORDER BY created_at DESC
LIMIT 50;
warning:
    EXPLAIN ANALYZE executes the query
    do not use it casually for destructive statements

analyze#

ANALYZE app.orders;

vacuum#

VACUUM app.orders;

VACUUM (ANALYZE) app.orders;

VACUUM (FULL, ANALYZE) app.orders;
VACUUM FULL:
    rewrites table
    takes strong lock
    needs extra disk space
    use only with maintenance window

table size#

SELECT
  pg_size_pretty(pg_total_relation_size('app.orders')) AS total_size,
  pg_size_pretty(pg_relation_size('app.orders')) AS table_size,
  pg_size_pretty(pg_indexes_size('app.orders')) AS indexes_size;

database size#

SELECT
  datname,
  pg_size_pretty(pg_database_size(datname)) AS size
FROM pg_database
ORDER BY pg_database_size(datname) DESC;

table row estimate#

SELECT
  schemaname,
  relname AS table_name,
  n_live_tup,
  n_dead_tup,
  last_vacuum,
  last_autovacuum,
  last_analyze,
  last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'app'
ORDER BY n_dead_tup DESC;

index usage#

SELECT
  schemaname,
  relname AS table_name,
  indexrelname AS index_name,
  idx_scan,
  idx_tup_read,
  idx_tup_fetch
FROM pg_stat_user_indexes
WHERE schemaname = 'app'
ORDER BY idx_scan ASC, indexrelname;

15. Session / Activity / Lock Debug#

current connection#

SELECT
  current_database(),
  current_user,
  session_user,
  inet_client_addr(),
  inet_server_addr(),
  pg_backend_pid();

active sessions#

SELECT
  pid,
  usename,
  datname,
  application_name,
  client_addr,
  state,
  wait_event_type,
  wait_event,
  now() - query_start AS query_age,
  left(query, 200) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY query_start NULLS LAST;

long running queries#

SELECT
  pid,
  usename,
  state,
  now() - query_start AS query_age,
  left(query, 500) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
  AND query_start < now() - interval '5 minutes'
ORDER BY query_start;

idle in transaction#

SELECT
  pid,
  usename,
  now() - xact_start AS transaction_age,
  state,
  left(query, 300) AS query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;

locks#

SELECT
  l.locktype,
  l.mode,
  l.granted,
  l.pid,
  a.usename,
  a.state,
  left(a.query, 200) AS query
FROM pg_locks l
LEFT JOIN pg_stat_activity a ON a.pid = l.pid
ORDER BY l.granted, l.pid;

blocking query#

SELECT
  blocked.pid AS blocked_pid,
  blocked.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks blocked_locks
  ON blocked_locks.pid = blocked.pid
JOIN pg_locks blocking_locks
  ON blocking_locks.locktype = blocked_locks.locktype
 AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
 AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
 AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
 AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
 AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
 AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
 AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
 AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
 AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
 AND blocking_locks.pid <> blocked_locks.pid
JOIN pg_stat_activity blocking
  ON blocking.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted
  AND blocking_locks.granted;

cancel / terminate#

SELECT pg_cancel_backend(12345);

SELECT pg_terminate_backend(12345);
rule:
    pg_cancel_backend asks the running query to stop
    pg_terminate_backend closes the backend connection
    prefer cancel first unless incident requires termination

16. Common Catalog Queries#

list tables and columns#

SELECT
  table_schema,
  table_name,
  column_name,
  ordinal_position,
  data_type,
  is_nullable,
  column_default
FROM information_schema.columns
WHERE table_schema = 'app'
ORDER BY table_name, ordinal_position;

primary keys#

SELECT
  tc.table_schema,
  tc.table_name,
  kcu.column_name,
  tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
  ON kcu.constraint_name = tc.constraint_name
 AND kcu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
  AND tc.table_schema = 'app'
ORDER BY tc.table_name, kcu.ordinal_position;

foreign keys#

SELECT
  tc.table_schema,
  tc.table_name,
  kcu.column_name,
  ccu.table_schema AS foreign_table_schema,
  ccu.table_name AS foreign_table_name,
  ccu.column_name AS foreign_column_name,
  tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
  ON tc.constraint_name = kcu.constraint_name
 AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
  ON ccu.constraint_name = tc.constraint_name
 AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
  AND tc.table_schema = 'app'
ORDER BY tc.table_name, tc.constraint_name;

indexes#

SELECT
  schemaname,
  tablename,
  indexname,
  indexdef
FROM pg_indexes
WHERE schemaname = 'app'
ORDER BY tablename, indexname;

table owner#

SELECT
  schemaname,
  tablename,
  tableowner
FROM pg_tables
WHERE schemaname = 'app'
ORDER BY tablename;

extensions#

SELECT
  extname,
  extversion
FROM pg_extension
ORDER BY extname;

installed settings#

SHOW server_version;
SHOW data_directory;
SHOW config_file;
SHOW hba_file;
SHOW max_connections;
SHOW shared_buffers;

17. Extension#

create extension#

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

use extension function#

SELECT gen_random_uuid();

drop extension#

DROP EXTENSION IF EXISTS pgcrypto;

18. Row Level Security#

enable RLS#

ALTER TABLE app.orders ENABLE ROW LEVEL SECURITY;

create policy#

CREATE POLICY orders_customer_policy
ON app.orders
FOR SELECT
USING (customer_id = current_setting('app.customer_id')::bigint);

set app context#

SET app.customer_id = '10001';

SELECT *
FROM app.orders;

disable RLS#

ALTER TABLE app.orders DISABLE ROW LEVEL SECURITY;

19. Practical Safety Checklist#

before DDL:
    check lock impact
    check table size
    check migration rollback plan
    avoid transaction block for CREATE INDEX CONCURRENTLY

before UPDATE / DELETE:
    run SELECT with same WHERE
    check estimated row count
    use RETURNING for audit when useful
    run in transaction for manual operation

before DROP / TRUNCATE:
    confirm environment and database
    list dependent objects
    confirm backup / restore point
    avoid CASCADE unless reviewed

before production query tuning:
    capture EXPLAIN (ANALYZE, BUFFERS)
    check index usage
    check row estimates
    check dead tuples and autovacuum state