Dimitri Fontaine's The Art of PostgreSQL makes the case that SQL is the most underused tool in a developer's toolkit. The book is aimed at application developers who use PostgreSQL through an ORM and argues you're missing much of what SQL offers if you only reach for it when the ORM falls short. These are some notes I took.
Part I - Preface
The book's premise is that most developers only use a fraction of what SQL/PostgreSQL can do, and don't know how to integrate raw SQL into a modern workflow (versioning, testing, review, deployment).
Central argument, in Dijkstra's terms: count "lines of code" as lines spent, not produced - writing more in SQL means writing (and maintaining) less overall.
Part II - Introduction
Ch 1 - Structured Query Language
SQL is declarative: you describe the desired result set, not the steps to get there; the model must be statically typed before the query runs.
SQL injection is really fixed by keeping query text and parameters separate at the database level, not by escaping strings yourself — PREPARE/EXECUTE and asyncpg's $1-style parameters show how.
generate_series() + LEFT JOIN + coalesce() can fill in missing rows (e.g. gaps in a date range) in one query, replacing what would otherwise be a manual loop in application code.
Ch 2 - Software Architecture
Think of PostgreSQL as a concurrent data-access service rather than just a storage layer, with SQL as its API.
ACID recap:
- Atomicity and isolation make safe concurrency possible
- Consistency depends on modeling and constraints
- Durability guarantees survival through crashes
Why PostgreSQL:
- Open governance, with development happening on public mailing lists
- Documentation required for every patch
- A "release when ready" cadence rather than a fixed schedule
- Deep extensibility - native JSON/XML/array/range types are built on a catalog-driven operator/function dispatch system, a legacy of its Berkeley/Stonebraker roots
Part III - Writing SQL Queries
Ch 4 - Business Logic
Every query already embeds business logic - the real question is how much logic belongs in SQL vs. app code.
Multi-query app-side logic risks reading across different snapshots (read-committed default) - concurrent writes between queries can silently corrupt results; a single SQL query is immune (one snapshot).
Network round-trips dominate cost as queries get cheap - 5 round trips vs. 1 well-written query matters more than server CPU time.
Prefer plain-SQL stored procedures (language sql) over PL/pgSQL; only reach for procedural code when set-based SQL genuinely can't express the logic.
Ch 5 - A Small Application
Keep queries in version-controlled .sql files instead of embedded strings - directly runnable in psql, diffable, reviewable like any other code.
Ch 6 - The SQL REPL (psql)
Recommends a tuned ~/.psqlrc (\x auto, ON_ERROR_ROLLBACK interactive, unicode borders, prompt showing transaction state) - but disable it for scripts/reports (--no-psqlrc).
psql doubles as a reporting tool (--tuples-only, -P format=html, -f script.sql); \set ECHO_HIDDEN true reveals the SQL behind backslash commands.
Ch 7 - SQL is Code
SQL deserves the same discipline as any code: consistent style, meaningful aliases, comments explaining why, version control, tests.
Avoid order by <column number>, pre-ANSI comma-joins, and natural join (silently changes semantics on schema drift).
Recommends pgTAP (pg_prove) for unit tests and RegreSQL for regression testing of .sql files.
Set application_name per module to trace production queries back to source.
Ch 8 - Indexing Strategy
Two reasons to index: (1) required to back UNIQUE/PRIMARY KEY/EXCLUDE constraints, (2) query performance - everything else is a deliberate trade-off (indexes cost write-side maintenance).
Access method cheat sheet:
- B-tree - general purpose, ordered data
- GiST - non-ordered, geometric, ranges
- SP-GiST - uneven-density tries/quadtrees
- GIN - arrays, full-text, composite containment
- BRIN - huge, naturally-ordered tables
- Hash - equality only
- Bloom - multi-column equality
Workflow: pg_stat_statements to find costly queries -> EXPLAIN (analyze, verbose, buffers) to spot seq scans and estimate/actual mismatches.
Ch 9 - Interview: Yohann Gabory
The database, not the framework, should enforce consistency; use the ORM for simple queries but always be able to inspect/override its generated SQL.
Rejects database-agnosticism as impractical - developing on SQLite and deploying on PostgreSQL means you never get to use PostgreSQL's real capabilities.
Part IV - SQL Toolbox
Ch 10-12 - Data, SQL, Statement Categories
SQL statements fall into four categories: changing data (INSERT/UPDATE/DELETE), changing schema (CREATE/ALTER/DROP), managing transactions (BEGIN/COMMIT/ROLLBACK), and managing permissions (GRANT/REVOKE). Notably, PostgreSQL treats schema changes as transactional, so they can be wrapped in a transaction and rolled back alongside data changes - something many other databases don't support.
Ch 13 - Select, From, Where
SELECT * is discouraged - hides intent, breaks silently on schema changes, wastes I/O (incl. TOAST decompression).
Join-condition placement matters: a filter in ON vs WHERE changes whether a LEFT JOIN stays outer or collapses to inner.
NOT IN + a NULL in the list means the predicate can never be true - use NOT EXISTS for anti-joins instead.
Ch 14 - Order By, Limit, No Offset
kNN (k-nearest neighbors) search answers questions like "what are the 5 closest stores to this address?" Normally that requires calculating the distance from the target to every row, then sorting and taking the top results - slow on a large table. PostgreSQL can instead write this as ORDER BY point <-> point LIMIT k (<-> is the distance operator), and if the column has a GiST index, the database walks the index outward from the target point, stopping as soon as it has k matches. No full scan, no separate sort step.
OFFSET is actively discouraged (must scan+discard all prior rows); proper pagination uses keyset/seek pagination: WHERE row(col1,col2) > (last1,last2) ORDER BY ... FETCH FIRST n ROWS ONLY.
Ch 15 - Group By, Having, With, Union All
FILTER (WHERE ...) restricts what an aggregate counts without a subquery.
GROUPING SETS/ROLLUP/CUBE compute multiple aggregation levels in one pass.
You can't nest aggregates directly - e.g. MAX(SUM(amount)) to find the customer with the highest total spend is invalid SQL, since SUM produces one row per customer and MAX needs those rows to already exist. The fix is to chain CTEs: one CTE computes SUM(amount) grouped by customer, then a second query runs MAX() over that result.
DISTINCT ON (expr) (PostgreSQL-only) picks first row per group per ORDER BY.
Prefer UNION ALL over UNION when duplicates are impossible; EXCEPT is a handy tool for regression-testing query equivalence.
Ch 16 - Understanding Nulls
SQL is three-valued logic (true/false/unknown) - NULL = NULL is NULL, not true.
Use IS DISTINCT FROM when NULL should compare like a normal value.
Ch 17 - Window Functions
Frames (default UNBOUNDED PRECEDING .. CURRENT ROW), PARTITION BY for subgroup scope, named WINDOW clauses for reuse.
Evaluated after WHERE filtering - can't see rows already excluded.
Ch 18 - Relations and Joins
A relation is a bag (duplicates allowed) of same-typed rows; join types: cross/inner/outer(left/right)/full outer/lateral; join conditions aren't limited to equality (non-equi joins are valid).
Ch 19 - Interview: Markus Winand
SQL feels hard because it's declarative - stop thinking "how would I loop this," describe the result instead.
Prefer standard SQL first (portability is as much about people/readability as about engines); DDL is the exception - don't try to make schema definitions portable.
Part V - Data Types
Ch 20-21 - Serialization & Relational Theory
A database's value is transactional consistency, not marshaling - storage alone isn't a hard problem.
PostgreSQL enforces real-world data-domain rules (date '2010-02-29' errors - 2010 isn't a leap year; no year zero exists).
Operators/functions dispatch polymorphically by argument type via catalogs (pg_operator, pg_opclass, pg_am).
Ch 22 - PostgreSQL Data Types
Boolean: three-valued; use IS TRUE/FALSE/NULL, not =.
Text: text and varchar(n) are internally identical (varchar is just text + a length check); never use SQL_ASCII encoding - use validated UTF8.
Numbers: no unsigned integer types by design (avoids operator-combination explosion); real/double precision can't exactly represent decimals like 0.1 - never use for money, use numeric or integer cents.
Sequences/serial: serial is a pseudo-type (int column + owned sequence); sequences are the one non-transactional object; use bigserial on high-volume tables to avoid the underlying int4 column overflowing before the sequence does.
UUID: always use the native uuid type (16 bytes) not text (37 bytes).
Date/Time: always use timestamptz, never bare timestamp - both are 8 bytes on disk, so there's no storage cost to the "safe" choice; timestamptz is stored as UTC and only converts on input/output based on session TimeZone. now() is frozen per-transaction; use clock_timestamp() to see real time advance.
Intervals: calendar-aware arithmetic against a real date (correctly handles month-length differences).
Ranges: e.g. daterange + GiST exclusion constraints (EXCLUDE USING gist (currency WITH =, validity WITH &&)) prevent overlapping validity periods - demonstrated with an exchange-rate table.
Ch 23 - Denormalized Data Types
Arrays: good for "whole unit" data like tags; if you're constantly querying into the array, that's a sign you need a real table; unnest() + GIN @> for efficient contains-search.
JSON vs JSONB: json is just validated text (preserves whitespace/duplicate keys, near zero operator support); jsonb is binary, normalized, and fully indexable - almost always the right choice. Best practice: typed columns for known fields + one jsonb column for the unpredictable rest.
Enum: mainly useful for MySQL migrations; PostgreSQL enums are named/shared types (unlike MySQL's anonymous per-column ones); a lookup table is often just as good.
Ch 24-25 - Extensions & Interview: Grégoire Hubert
Deploy postgresql-contrib from day one in dev and prod.
POMM's design keeps model entities "database-ignorant" and shaped by the SQL projection, deliberately avoiding Active Record; JSON/XML in the DB is valuable for genuinely extensible fields that don't need relational consistency enforcement.
Part VI - Data Modeling
Ch 26 - ORM
Application object models (user workflows) and the database model (consistent view of the whole world) serve different purposes - sharing one model between them tends to produce monoliths and technical debt.
Ch 27 - Modeling Tooling
Use begin;/rollback; in psql to iterate schema drafts without committing half-finished changes; random-data generators and Lorem Ipsum tables validate designs at realistic volume.
Nested lateral joins solve Top-N-per-category queries; adding the right index on top can meaningfully cut runtime further.
Ch 28 - Normalization
Normal forms:
- 1NF - atomic values, no duplicates
- 2NF - no partial key dependency
- 3NF - no transitive dependency
- BCNF/4NF/5NF/DKNF - stricter guarantees; aim for BCNF/4NF in practice
Three classic anomalies from under-normalizing:
- Update - duplicated facts go out of sync
- Insertion - can't record one fact without an unrelated one
- Deletion - deleting one fact forces deleting another
Worked "address field" example: normalization needs depend on the use case (a text field is fine for invoicing; a logistics business needs a full geographic hierarchy).
Surrogate keys (e.g. bigserial) don't prevent business-level duplicates on their own - pair with a unique() constraint on the natural key too.
Ch 29 - Geonames Case Study
Compound codes can be split into reference tables and comma-separated fields split into association tables via regexp_split_to_table; a GiST point index keeps nearest-neighbor lookups fast even at tens of millions of rows.
Ch 30 - Modelization Anti-Patterns
EAV (entity-attribute-value) is called out as one of the worst patterns possible: loses type safety, allows silent typo'd "phantom" parameters, forces app-side pivoting.
Multiple values per column (delimited text) breaks 1NF, kills indexed search and per-value stats.
UUID as a silver bullet: still just a surrogate key - doesn't solve the "what's the natural business key" problem any better than a sequence does.
Ch 31 - Denormalization
Normalize first, measure real query timings (avg/median/p95/p99), and only denormalize with hard evidence - otherwise it's premature optimization.
Materialized views as caches (build a normal view first, then materialize on top; refresh ... concurrently avoids blocking readers).
History/audit tables, or a generic JSONB-snapshot archive table via row_to_json.
Native table partitioning (PG10+) has real costs: no cross-partition PK/unique/exclusion constraints, no FKs referencing partitioned tables, no ON CONFLICT - effectively loses 1NF guarantees at the whole-table level.
Ch 32 - Not Only SQL
PostgreSQL's "schemaless" JSONB isn't truly schemaless - the shape just moves from the catalog into application code.
synchronous_commit can be tuned per-transaction to trade write latency against durability guarantees.
No native scale-out in core PostgreSQL - requires extensions (Citus, BDR) or PG10 logical replication.
Ch 33 - Interview: Álvaro Hernández Tortosa (ToroDB)
Rejects "schemaless" - prefers "dynamic schema"; even MongoDB data ends up having real relations.
Benchmarks showed relational storage beating NoSQL on analytics by 1-2 orders of magnitude, since unstructured stores must infer structure at query time.
Recommended sweet spot: normalized core schema + JSONB for evolving fields, promoting fields to real columns once their shape stabilizes.
Part VII - Data Manipulation and Concurrency Control
Ch 35 - Insert, Update, Delete
RETURNING (PostgreSQL extension) avoids a round-trip to learn generated/default values.
UPDATE internally does insert-new + mark-old-invisible under MVCC (xmin/xmax); row locks are per-tuple.
DELETE ... RETURNING wrapped in a CTE is the recommended default pattern for interactive deletes (gives a summary of what was removed).
TRUNCATE is technically DDL - bypasses per-tuple MVCC but is still transactional/rollback-safe.
Ch 36 - Isolation and Locking
PostgreSQL doesn't implement read-uncommitted; defaults to read-committed; disallows phantom reads at repeatable-read (stricter than the SQL standard requires).
Serializable Snapshot Isolation (SSI) guarantees the same effect as if transactions ran one at a time.
Key concurrency-modeling lesson: replace shared-counter UPDATE columns with an append-only activity/event table - updating one popular row serializes all concurrent writers, while inserting into a fresh table never contends. Benchmark shows the insert-based design clearly outperforming the update-based one under load.
Ch 37 - Computing and Caching in SQL
Views: computed at query time, no caching, no invalidation problem, but no performance win either.
Materialized views: persisted snapshots (refresh ... concurrently needs a unique index) - the real "invalidation policy" is just your refresh schedule.
Ch 38 - Triggers
Must be written in a procedural language (PL/pgSQL etc.) - plain SQL can't implement a trigger function.
The classic "counter trigger" anti-pattern (check-then-update-or-insert) reintroduces exactly the single-row contention problem Ch.36 solved, and has a race condition on first-insert-of-the-day; fix correctness with INSERT ... ON CONFLICT DO UPDATE, but the underlying scalability bottleneck (still hammering one row) remains.
Ch 39 - Listen and Notify
Async pub/sub over the wire protocol (pg_notify), useful for cache-refresh/invalidation daemons - but fire-and-forget with no durable queueing, so it can't be used as a guaranteed-delivery queue.
Ch 40 - Batch Update (MoMA)
Standard pattern:
- Stage the new snapshot in a temp table
UPDATE ... FROM stagingon the natural key (skip no-op rows via row-value<>comparison)INSERT ... SELECT WHERE NOT EXISTSfor new rows
Concurrent re-runs risk duplicate-key errors; fix with LOCK TABLE serialization or INSERT ... ON CONFLICT DO NOTHING.
Ch 41 - Interview: Kris Jenkins (YeSQL)
"Performance is the tail, correctness is the dog" - get transaction boundaries right first, tune for real observed hotspots later, not speculatively.
Part VIII - PostgreSQL Extensions
Ch 42 - What's an Extension?
Extensions add catalog objects (functions, types, operators, index access methods) via create extension, no restart needed; notable ones: Bloom, earthdistance, hstore, ltree, pg_trgm (contrib), PostGIS, ip4r, Citus, pg_partman, postgres-hll, MADlib (external).
Ch 43 - Auditing with hstore
hstore's - (diff) operator between hstore(old) and hstore(new) in an after update trigger gives a clean before/after audit trail with no native JSONB equivalent for this specific diff capability.
Ch 45 - Trigrams for Typos (pg_trgm)
similarity()/% operator for fuzzy/typo-tolerant matching (case-insensitive, unlike regex); word_similarity()/%> for matching a word against multi-word titles; GiST trigram index turns both regex and similarity search into single-digit-millisecond queries.
Ch 46 - Tags with intarray
query_int + @@ lets you evaluate boolean tag queries (3&739, i.e. AND/OR/NOT) against a GIN-indexed integer array of tag IDs - avoids the awkward multi-way joins a junction-table design would need for combined tag filters.
Ch 47-49 - Geolocation (pubs, earthdistance, ip4r)
kNN pattern ORDER BY pos <-> point(...) LIMIT n with a GiST index turns a 20ms sequential scan into a sub-1ms indexed nearest-neighbor search.
earthdistance's <@> gives real Earth-surface miles between points; ip4r + GiST gives sub-millisecond "which network contains this IP" lookups.
Ch 50 - HyperLogLog
hll type gives approximate distinct counts in a small fixed size (~1280 bytes for tens of billions of values); hll_union_agg correctly deduplicates when rolling up daily counts into weekly/monthly ones (naive summing of daily uniques would double-count).
Ch 51 - Interview: Craig Kerstiens (Citus Data)
Considers PostgreSQL's extension ecosystem the biggest advancement of the last decade - it turned a relational database into a broader data platform while letting core PostgreSQL move cautiously.
Main risk: managed/cloud PostgreSQL providers only support a limited extension allowlist.
Part IX - Closing Thoughts
The book closes by circling back to its opening argument: SQL is a full programming language in its own right, and should be treated that way. That means writing one query per result set instead of assembling data in the app, and running SQL through the same lifecycle as any other code - spec it, test it, review it, and rewrite it as requirements change - rather than writing it once and never touching it again.