PostgreSQL Cheatsheet

Extensions

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

Installing and inspecting

An extension is a packaged bundle of types, functions, operators, and index operator classes. Installing one is a per-database operation, so a new database on the same cluster starts with none of them.

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA ext;   -- keep it out of public
CREATE EXTENSION IF NOT EXISTS pg_trgm VERSION '1.6';

ALTER EXTENSION pg_trgm UPDATE;            -- to the newest available version
ALTER EXTENSION pg_trgm UPDATE TO '1.6';   -- to a specific one
ALTER EXTENSION hstore SET SCHEMA public;

DROP EXTENSION pg_trgm;            -- fails if anything depends on it
DROP EXTENSION pg_trgm CASCADE;    -- drops the dependent indexes too
CommandShows
\dxInstalled extensions, version, schema, description
\dx+ pg_trgmEvery object the extension owns
\dx pg*Installed extensions matching a pattern
SELECT * FROM pg_available_extensionsEverything the server has files for
SELECT * FROM pg_extensionThe catalog behind \dx
-- What can I install, and is it already installed?
SELECT name, default_version, installed_version, comment
FROM pg_available_extensions
WHERE name IN ('pg_trgm','pgcrypto','vector','postgis')
ORDER BY name;

Two things bite. CREATE EXTENSION needs superuser for most contrib modules, and a managed provider like RDS or Cloud SQL only lets you install from its own allow list. And pg_dump dumps extensions as a bare CREATE EXTENSION line, not as the objects inside, so the target server must have the same extension files on disk or the restore fails.

Fuzzy text with pg_trgm

pg_trgm breaks strings into three-character trigrams and compares the sets. It is how you index a leading-wildcard LIKE and how you build "did you mean" search without a full-text setup.

Operator or functionMeaning
similarity(a, b)0 to 1 trigram overlap
a % bTrue when similarity clears pg_trgm.similarity_threshold (0.3)
a <-> bDistance, 1 - similarity, usable in ORDER BY
word_similarity(a, b)Best match of a against any word run inside b
a <<% bWord-similarity operator, threshold pg_trgm.word_similarity_threshold
show_trgm('cat')The trigram array, for debugging a bad match
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX products_name_trgm ON products USING gin (name gin_trgm_ops);

-- Indexed leading-wildcard search
SELECT id, name FROM products WHERE name ILIKE '%widg%';

-- Ranked fuzzy match, needs gist_trgm_ops for the KNN ordering
CREATE INDEX products_name_trgm_gist ON products USING gist (name gist_trgm_ops);

SET pg_trgm.similarity_threshold = 0.25;
SELECT name, round(similarity(name, 'wigdet')::numeric, 3) AS sim
FROM products
WHERE name % 'wigdet'
ORDER BY name <-> 'wigdet'
LIMIT 10;

gin_trgm_ops is faster for containment, gist_trgm_ops is the one that answers ORDER BY col <-> $1 as an index scan. Trigram indexes are large, often bigger than the column, and a search string shorter than three characters has no trigrams at all so it degrades to a scan.

citext, unaccent, and hstore

ExtensionGives youReach for it when
citextA case-insensitive text typeEmails and usernames compared case-blind everywhere
unaccentunaccent(text), strips diacriticsJosé should match Jose
hstoreFlat text => text map with GiST/GIN supportLegacy tag bags, prefer jsonb in new work
ltreeMaterialized-path tree type, ~ <@ @>Category trees queried by ancestor
fuzzystrmatchlevenshtein, soundex, metaphone, daitch_mokotoffName matching where trigrams are too loose
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE users (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                    email citext UNIQUE NOT NULL);
SELECT 'Ada@Example.COM'::citext = 'ada@example.com';   -- t

CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT unaccent('Jose Muñoz Ángel');                    -- Jose Munoz Angel

-- unaccent is STABLE, so wrap it before putting it in an index
CREATE FUNCTION immutable_unaccent(text) RETURNS text
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT AS
$$ SELECT public.unaccent('public.unaccent'::regdictionary, $1) $$;

CREATE INDEX people_name_unaccent
  ON people (lower(immutable_unaccent(full_name)));

CREATE EXTENSION IF NOT EXISTS hstore;
SELECT 'a=>1, b=>2'::hstore -> 'b';                     -- 2
SELECT akeys('a=>1, b=>2'::hstore);                     -- {a,b}

Both unaccent forms are declared STABLE, not IMMUTABLE, because a dictionary file can change, so an index expression needs the wrapper above. Pass the dictionary explicitly inside it or the wrapper depends on search_path. citext compares with lower() under the hood, which means it follows the database collation and is not a substitute for real Unicode case folding on every alphabet.

btree_gin and btree_gist

These add btree-style operator classes to GIN and GiST, so a scalar column can share one index with a jsonb, array, or range column. That is the whole point: one multicolumn index instead of two.

CREATE EXTENSION IF NOT EXISTS btree_gin;
CREATE EXTENSION IF NOT EXISTS btree_gist;

-- tenant_id is a plain int, tags is text[], one GIN index serves both
CREATE INDEX docs_tenant_tags ON docs USING gin (tenant_id, tags);
SELECT * FROM docs WHERE tenant_id = 7 AND tags @> ARRAY['urgent'];
-- Bitmap Index Scan, Index Cond: ((tenant_id = 7) AND (tags @> '{urgent}'))

-- btree_gist makes = available to an EXCLUDE constraint
CREATE TABLE bookings (
  room_id  int  NOT NULL,
  during   tstzrange NOT NULL,
  EXCLUDE USING gist (room_id WITH =, during WITH &&)
);

Three things to know:

- They are not faster than a real btree for scalar-only queries, so never replace a btree with them. They exist purely so a scalar can join a GIN or GiST index that a btree cannot enter. - The literal must match the column type exactly. On a bigint column, tenant_id = 7 silently drops to a Filter because the operator is bigint = integer, and tenant_id = 7::bigint becomes an Index Cond. - A GIN index has no column order, so gin (a, b) and gin (b, a) are equivalent.

See Constraints for the EXCLUDE story and Indexes for when GIN pays off.

pgcrypto and UUIDs

gen_random_uuid() has been in core since 13, so uuid-ossp is only needed for the older v1 and v3 generators. pgcrypto is for hashing, HMAC, password hashes, and random bytes.

FunctionExtensionReturns
gen_random_uuid()core, 13+Random v4 uuid
uuidv7()core, 18+Time-ordered v7 uuid, better index locality
uuid_generate_v4()uuid-osspSame as gen_random_uuid, redundant now
uuid_generate_v1mc()uuid-osspTime based, random MAC
gen_random_bytes(n)pgcrypton cryptographic random bytes
digest(data, 'sha256')pgcryptobytea hash
hmac(data, key, 'sha256')pgcryptoKeyed hash
crypt(pw, gen_salt('bf', 10))pgcryptobcrypt password hash
CREATE EXTENSION IF NOT EXISTS pgcrypto;

SELECT encode(digest('hello', 'sha256'), 'hex');
SELECT encode(gen_random_bytes(16), 'base64');          -- an API token

-- Password verify: hash the candidate with the stored hash as the salt
INSERT INTO accounts (email, pw_hash)
VALUES ($1, crypt($2, gen_salt('bf', 10)));

SELECT id FROM accounts
WHERE email = $1 AND pw_hash = crypt($2, pw_hash);

Note that sha256(bytea) is in core since 11, so a plain hash needs no extension. Doing password hashing in the database means the plaintext travels in the query and can land in log_statement output, so most applications hash in the application instead.

pgvector for embeddings

pgvector adds a vector type plus HNSW and IVFFlat indexes for approximate nearest-neighbour search. It is not in core, so install it from packages or your provider's list.

OperatorDistancePair it with
<->L2 (Euclidean)vector_l2_ops
<=>Cosinevector_cosine_ops
<#>Negative inner productvector_ip_ops
<+>L1 (taxicab)vector_l1_ops
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  doc_id    bigint NOT NULL REFERENCES docs (id) ON DELETE CASCADE,
  body      text   NOT NULL,
  embedding vector(1536) NOT NULL          -- text-embedding-3-small width
);

CREATE INDEX chunks_embedding_hnsw ON chunks
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- Top 5 nearest, with cosine similarity reported alongside
SET hnsw.ef_search = 100;                  -- default 40, higher = better recall
SELECT id, body, 1 - (embedding <=> $1) AS similarity
FROM chunks
WHERE doc_id = $2
ORDER BY embedding <=> $1
LIMIT 5;

The rules that matter:

- The index operator class must match the query operator. An hnsw (… vector_l2_ops) index does nothing for an ORDER BY embedding <=> $1. - Results are approximate. Raise hnsw.ef_search (or ivfflat.probes) for recall, lower it for speed. - vector tops out at 2000 dimensions for an index. halfvec reaches 4000 at half the storage, and sparsevec holds up to 1000 non-zero entries. - Build indexes with a large maintenance_work_mem, and watch progress in pg_stat_progress_create_index. - IVFFlat needs representative data present before you build it, because it clusters what it sees. HNSW does not, so it is the default choice.

pg_stat_statements

The single most useful extension on a production server. It aggregates normalized query text with call counts and timings, which is how you find the query that is actually costing you.

# postgresql.conf, needs a restart
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = top
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT calls,
       round(total_exec_time::numeric, 1)  AS total_ms,
       round(mean_exec_time::numeric, 2)   AS mean_ms,
       rows,
       left(query, 90) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

SELECT pg_stat_statements_reset();          -- baseline before a load test

Order by total_exec_time, not mean_exec_time. A 3 ms query called two million times an hour hurts far more than a 400 ms report nobody runs. See EXPLAIN and Performance for what to do once you have the offender.