PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 31: Extensions

Useful extensions: pgcrypto, citext, hstore, ltree and btree_gist

Level 6: Design and Security30 min

A handful of contrib extensions solve common problems neatly: password hashing and encryption (pgcrypto), case-insensitive text (citext), simple key-value columns (hstore), hierarchies (ltree) and exclusion constraints that mix equality with ranges (btree_gist).

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Hash passwords with crypt() and bcrypt, and know the limits of in-database encryption
  • Model hierarchies with ltree and case-insensitive keys with citext
  • Prevent double bookings with btree_gist and an exclusion constraint

conceptThe concept

  • pgcrypto: crypt(password, gen_salt('bf')) produces a bcrypt hash; verifying is stored = crypt(attempt, stored). pgp_sym_encrypt and pgp_sym_decrypt encrypt values with a key.
  • citext: a text type whose comparisons ignore case, so a UNIQUE constraint on an email column treats Ann@x.com and ann@X.com as duplicates.
  • hstore: a flat map of text keys to text values. Today JSONB is usually the better choice, but hstore remains common in older schemas.
  • ltree: label paths such as shop.home.lighting with operators for ancestors (@>) and descendants (<@) and GiST index support.
  • btree_gist: GiST operator classes for plain types, needed when an exclusion constraint combines = on a column with && on a range.

whyWhy it matters

Each of these replaces application code that is easy to get subtly wrong: case-insensitive uniqueness checks with race conditions, recursive tree queries, or double-booking checks done with SELECT-then-INSERT.

Use caseReal-world scenario

A meeting-room service stores bookings with a room id and a tstzrange. One exclusion constraint guarantees no two bookings for the same room overlap, even under concurrent inserts.

Try itExamples

Run and edit these in the interactive lesson.

pgcrypto: hashing and verifying passwords
BEGIN;
CREATE EXTENSION pgcrypto;
CREATE TEMP TABLE app_users (email text PRIMARY KEY, pw_hash text NOT NULL);
INSERT INTO app_users VALUES ('ann@example.com', crypt('correct horse', gen_salt('bf', 8)));
SELECT left(pw_hash, 7) AS algorithm_and_cost,
       pw_hash = crypt('correct horse', pw_hash) AS right_password,
       pw_hash = crypt('wrong', pw_hash) AS wrong_password
FROM app_users;
ROLLBACK;
citext: case-insensitive unique emails
BEGIN;
CREATE EXTENSION citext;
CREATE TEMP TABLE subscribers (email citext PRIMARY KEY);
INSERT INTO subscribers VALUES ('Ann@Example.com');
SELECT email, email = 'ann@example.COM' AS matches FROM subscribers;
INSERT INTO subscribers VALUES ('ann@example.com');
The second insert violates the primary key because citext compares case-insensitively.
ltree: a category hierarchy
BEGIN;
CREATE EXTENSION ltree;
CREATE TEMP TABLE categories (path ltree PRIMARY KEY);
INSERT INTO categories VALUES ('shop'), ('shop.home'), ('shop.home.lighting'), ('shop.home.kitchen'),
  ('shop.sports'), ('shop.sports.cycling'), ('shop.sports.cycling.helmets');
SELECT path, nlevel(path) AS depth FROM categories WHERE path <@ 'shop.sports' ORDER BY path;
SELECT path FROM categories WHERE path @> 'shop.sports.cycling.helmets' ORDER BY nlevel(path);
ROLLBACK;
btree_gist: no overlapping bookings per room
BEGIN;
CREATE EXTENSION btree_gist;
CREATE TEMP TABLE bookings (
  room int NOT NULL,
  during tstzrange NOT NULL,
  EXCLUDE USING gist (room WITH =, during WITH &&)
);
INSERT INTO bookings VALUES (1, '[2025-11-10 09:00+00, 2025-11-10 10:00+00)'), (2, '[2025-11-10 09:30+00, 2025-11-10 10:30+00)');
INSERT INTO bookings VALUES (1, '[2025-11-10 09:30+00, 2025-11-10 11:00+00)');
Room 2 may overlap room 1, but the second booking for room 1 conflicts and is rejected with an exclusion_violation.
hstore next to JSONB
BEGIN;
CREATE EXTENSION hstore;
SELECT h -> 'color' AS color, h ? 'size' AS has_size, hstore_to_jsonb(h) AS as_jsonb
FROM (SELECT 'color=>red, size=>XL, fit=>slim'::hstore AS h) s;
ROLLBACK;

internalsUnder the hood

bcrypt with cost 8 to 12 takes milliseconds to hundreds of milliseconds per hash by design, which is why it resists brute force. ltree paths are stored as a compact label array; GiST indexes on them support ancestor and descendant searches without recursive CTEs.

Avoid / preferDo this, not that

Avoid
INSERT INTO users (email, pw)
VALUES ($1, md5($2));
Prefer
INSERT INTO users (email, pw_hash)
VALUES ($1, crypt($2, gen_salt('bf', 12)));

MD5 is fast and unsalted here, so stolen hashes are cracked in seconds. bcrypt is salted and deliberately slow. Many teams hash in the application instead, so the plain password never reaches the database or its logs.

Avoid
-- check, then insert: races under concurrency
SELECT 1 FROM bookings WHERE room = $1 AND during && $2;
INSERT INTO bookings ...;
Prefer
EXCLUDE USING gist (room WITH =, during WITH &&)

Two sessions can both pass the SELECT and both insert. The exclusion constraint is enforced by the index and cannot be raced.

PitfallsCommon mistakes

  • Sending plain passwords or encryption keys in SQL that is logged by log_statement or pg_stat_statements.
  • Using hstore for new nested data; JSONB supports nesting, types and SQL/JSON path.
  • Forgetting a GiST index on ltree columns and querying a large tree with sequential scans.

LimitsWhen not to use it

In-database encryption with pgcrypto protects data at rest from someone reading table files or backups, but the key travels with every query, so it does not protect against a DBA or anyone who can see query logs. For strong guarantees use application-side or client-side encryption with a key management service.

SecuritySecurity notes

Set log_statement and log_min_duration_statement so they do not capture statements containing secrets, and remember pg_stat_statements stores normalised query text without literal values. Prefer hashing passwords in the application with a modern algorithm (argon2id or bcrypt); if you hash in the database, use bcrypt with a cost of at least 10.

ChallengeTry it yourself

Using ltree inside a transaction, build a small tree of the employees table from manager to report (paths like 1.2.5), then list everyone under employee 2 (Linus Okafor) with their depth below him.

Show one solution
One solution
BEGIN;
CREATE EXTENSION ltree;
CREATE TEMP TABLE org AS
WITH RECURSIVE t AS (
  SELECT id, name, id::text AS p FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.id, e.name, t.p || '.' || e.id FROM employees e JOIN t ON e.manager_id = t.id
)
SELECT id, name, p::ltree AS path FROM t;
SELECT name, nlevel(path) - nlevel('1.2') AS depth_below
FROM org WHERE path <@ '1.2' AND path <> '1.2'
ORDER BY path;
ROLLBACK;

InterviewQuestions you should be able to answer

How do you store passwords in PostgreSQL?
Never in plain text or with a fast hash like MD5 or SHA-256 alone. Store a salted, slow hash: bcrypt via pgcrypto (crypt with gen_salt('bf', 10+)) or, better, hash in the application with argon2id so the plain password never reaches the database or its logs. Verify by recomputing the hash with the stored salt. Keep the column readable only by the authentication role.
How do you guarantee no double bookings at the database level?
Use a range column (tstzrange) and an exclusion constraint: EXCLUDE USING gist (room_id WITH =, during WITH &&), which needs the btree_gist extension for the equality part. The constraint is enforced by a GiST index, so concurrent inserts cannot both succeed. Since PostgreSQL 18 a temporal primary key with WITHOUT OVERLAPS expresses the same rule declaratively.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation