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
| Command | Shows |
|---|---|
\dx | Installed extensions, version, schema, description |
\dx+ pg_trgm | Every object the extension owns |
\dx pg* | Installed extensions matching a pattern |
SELECT * FROM pg_available_extensions | Everything the server has files for |
SELECT * FROM pg_extension | The 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 function | Meaning |
|---|---|
similarity(a, b) | 0 to 1 trigram overlap |
a % b | True when similarity clears pg_trgm.similarity_threshold (0.3) |
a <-> b | Distance, 1 - similarity, usable in ORDER BY |
word_similarity(a, b) | Best match of a against any word run inside b |
a <<% b | Word-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
| Extension | Gives you | Reach for it when |
|---|---|---|
citext | A case-insensitive text type | Emails and usernames compared case-blind everywhere |
unaccent | unaccent(text), strips diacritics | José should match Jose |
hstore | Flat text => text map with GiST/GIN support | Legacy tag bags, prefer jsonb in new work |
ltree | Materialized-path tree type, ~ <@ @> | Category trees queried by ancestor |
fuzzystrmatch | levenshtein, soundex, metaphone, daitch_mokotoff | Name 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.
| Function | Extension | Returns |
|---|---|---|
gen_random_uuid() | core, 13+ | Random v4 uuid |
uuidv7() | core, 18+ | Time-ordered v7 uuid, better index locality |
uuid_generate_v4() | uuid-ossp | Same as gen_random_uuid, redundant now |
uuid_generate_v1mc() | uuid-ossp | Time based, random MAC |
gen_random_bytes(n) | pgcrypto | n cryptographic random bytes |
digest(data, 'sha256') | pgcrypto | bytea hash |
hmac(data, key, 'sha256') | pgcrypto | Keyed hash |
crypt(pw, gen_salt('bf', 10)) | pgcrypto | bcrypt 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.
| Operator | Distance | Pair it with |
|---|---|---|
<-> | L2 (Euclidean) | vector_l2_ops |
<=> | Cosine | vector_cosine_ops |
<#> | Negative inner product | vector_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.
postgres_fdw, dblink, and the rest
postgres_fdw maps remote tables into the local catalog and pushes down predicates
and joins. dblink just runs a string on the far side and hands back rows, so
reach for the FDW unless you need a one-off or a separate transaction.
CREATE EXTENSION IF NOT EXISTS postgres_fdw; CREATE SERVER reporting FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host 'db2.internal', port '5432', dbname 'analytics'); CREATE USER MAPPING FOR app SERVER reporting OPTIONS (user 'app_ro', password 'secret'); CREATE SCHEMA remote; IMPORT FOREIGN SCHEMA public LIMIT TO (sales, customers) FROM SERVER reporting INTO remote; SELECT count(*) FROM remote.sales WHERE placed_at >= current_date - 7; ANALYZE remote.sales; -- foreign tables need their own stats
CREATE EXTENSION IF NOT EXISTS dblink; SELECT * FROM dblink('dbname=analytics host=db2.internal user=app_ro', 'SELECT count(*) FROM sales') AS t(total bigint);
| Extension | What it adds | In core contrib? |
|---|---|---|
postgis | Geometry, geography, spatial indexes, ST_* functions | No |
timescaledb | Hypertables, chunk pruning, continuous aggregates, retention | No |
pg_cron | A cron scheduler running inside the server | No |
pg_partman | Automated partition creation and retention | No |
pg_repack | Rebuild a bloated table without an exclusive lock | No |
tablefunc | crosstab() pivot tables | Yes |
pageinspect, pg_buffercache | Low-level page and buffer inspection | Yes |
-- PostGIS: nearest stores to a point, index-assisted CREATE EXTENSION IF NOT EXISTS postgis; ALTER TABLE stores ADD COLUMN geog geography(Point, 4326); CREATE INDEX stores_geog_gix ON stores USING gist (geog); SELECT name, ST_Distance(geog, ST_MakePoint(-73.98, 40.75)::geography) AS m FROM stores WHERE ST_DWithin(geog, ST_MakePoint(-73.98, 40.75)::geography, 2000) ORDER BY geog <-> ST_MakePoint(-73.98, 40.75)::geography LIMIT 10;
-- TimescaleDB: a time-partitioned table, then drop old chunks CREATE EXTENSION IF NOT EXISTS timescaledb; CREATE TABLE readings ( ts timestamptz NOT NULL, device text NOT NULL, value double precision ) WITH (tsdb.hypertable, tsdb.orderby = 'ts DESC'); SELECT drop_chunks('readings', INTERVAL '3 months'); SELECT show_chunks('readings');
-- pg_cron: schedules live in one database, jobs can target any CREATE EXTENSION IF NOT EXISTS pg_cron; SELECT cron.schedule('nightly-vacuum', '0 3 * * *', $$VACUUM (ANALYZE) events$$); SELECT cron.schedule_in_database('purge', '*/15 * * * *', 'DELETE FROM sessions WHERE expires_at < now()', 'app_prod'); SELECT * FROM cron.job; SELECT cron.unschedule('nightly-vacuum');
pg_cron needs shared_preload_libraries = 'pg_cron' and a restart, and its own
tables live in the database named by cron.database_name. Job history lands in
cron.job_run_details, which grows forever unless you prune it.