Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
postgresqldatabasessqlbackendperformance

PostgreSQL in practice: one database that replaces five systems

A working guide to PostgreSQL 18 - JSONB, indexes, EXPLAIN, queues, full-text and vector search, production operations, and a learning path that works.

T

team

19 min read
A stylised illustration of a database, with a lock on it and a small footprint. The database is surrounded by a network of pipes and gears, representing the infrastructure that supports it.

Most teams reach for PostgreSQL as their relational database and stop there. Then, over the next two years, they add Redis for the cache, Elasticsearch for search, RabbitMQ for the job queue, MongoDB for the flexible documents, and a vector database for the AI feature. Five systems to back up, monitor, secure and keep consistent - when the database they started with could have done all five.

This is a long, practical guide to PostgreSQL: what it is, the features that make it different, working tutorials you can follow along with, the extensions that expand what it covers, the mistakes that bite people, and an honest account of where it runs out of road.

What PostgreSQL actually is

PostgreSQL is an open-source object-relational database with roots in the POSTGRES project at Berkeley in the 1980s. It is developed by a global group rather than a company, released under a permissive licence, and has no paid tier, no enterprise edition and no feature held back for customers. What you download is the whole thing.

That governance model explains a lot about how it behaves. Releases are annual and boring. Correctness wins over speed in design arguments. Features arrive when they are finished rather than when a quarter ends. Nobody can buy it and change the licence, which after the last few years of database licence changes is worth something on its own.

The design decision that shapes everything

PostgreSQL was built to be extended. Types, operators, index methods, functions, languages and even foreign data wrappers are all things you can add without patching the server. Extensions are not a plugin afterthought - the catalog was designed for them from the beginning.

This is why PostgreSQL keeps absorbing categories other people build separate products for. Geospatial became PostGIS. Time series became TimescaleDB. Vector search became pgvector. Each arrived as an extension rather than a fork, and each inherited transactions, backups, replication, security and the query planner for free.

What is new in 18

FeatureWhy it matters
Asynchronous I/O subsystemUp to 3× improvement on reads from storage for sequential scans, bitmap heap scans and vacuum
B-tree skip scanMulticolumn indexes now usable when you omit an equality condition on a leading column
uuidv7()Timestamp-ordered UUIDs, which index far better than random v4 values
Planner statistics survive pg_upgradeNo more performance cliff after a major upgrade while ANALYZE catches up
Virtual generated columnsComputed at read time, now the default for generated columns
Temporal constraintsPRIMARY KEY, UNIQUE and FOREIGN KEY over ranges - non-overlapping periods enforced by the database
OLD and NEW in RETURNINGSee both before and after values from INSERT, UPDATE, DELETE and MERGE
OAuth authenticationToken-based auth without an external proxy
Data checksums on by defaultinitdb now enables them; corruption gets detected rather than silently spreading
MD5 password authentication is deprecated in 18 and will be removed. If your roles still use it, CREATE ROLE and ALTER ROLE now warn you.

Getting started

Install and connect

bash
# macOS
brew install postgresql@18
brew services start postgresql@18

# Debian and Ubuntu - use the PGDG repository, not the distro package
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-18

# Docker - the fastest way to get a throwaway instance
docker run --name pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres:18
The distro package is often a major version or two behind. The PGDG repository is the one the project maintains.
bash
createdb shop
psql shop

-- or connect with a URL
psql postgresql://user:secret@localhost:5432/shop

psql, which is worth ten minutes of your life

The built-in client is far better than most people realise. Learning half a dozen backslash commands removes most of your reasons to open a GUI.

sql
\l              -- list databases
\c shop         -- connect to a database
\dt             -- list tables
\d orders       -- describe a table: columns, indexes, constraints, triggers
\di             -- list indexes
\df            -- list functions
\dx             -- list installed extensions
\x              -- toggle expanded output - essential for wide rows
\timing         -- show how long each query takes
\e              -- edit the last query in $EDITOR
\i script.sql   -- run a file
\copy (SELECT * FROM orders) TO 'orders.csv' CSV HEADER   -- export client-side
\watch 2        -- rerun the last query every 2 seconds
\?              -- all of them
\q              -- quit
\x auto is the setting most people want - expanded output only when the row is too wide for the terminal.

A first schema

sql
CREATE TABLE customers (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email       text NOT NULL UNIQUE,
    full_name   text NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE orders (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id  bigint NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
    status       text NOT NULL DEFAULT 'pending',
    total_cents  integer NOT NULL,
    placed_at    timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT orders_total_positive CHECK (total_cents > 0),
    CONSTRAINT orders_status_valid
        CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled'))
);

CREATE INDEX orders_customer_placed_idx
    ON orders (customer_id, placed_at DESC);
Four decisions worth copying: identity columns over serial, timestamptz over timestamp, money as integer cents, and constraints written at creation rather than added later.

The type system is the selling point

Most databases give you numbers, strings and dates. PostgreSQL gives you a type system rich enough that a lot of application-level validation becomes unnecessary.

JSONB - documents inside a relational database

jsonb stores JSON in a parsed binary form: slower to write than raw text, far faster to query, and indexable. It means you can keep the structured 90% of your data in columns and the genuinely variable 10% in a document, in the same row, in the same transaction.

sql
ALTER TABLE orders ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}';

UPDATE orders
SET metadata = '{"channel": "mobile", "coupon": "EID25", "items": 3}'
WHERE id = 1;

-- -> returns jsonb, ->> returns text
SELECT id, metadata ->> 'channel' AS channel
FROM orders
WHERE metadata ->> 'coupon' = 'EID25';

-- @> is containment, and it is the operator a GIN index accelerates
SELECT id FROM orders WHERE metadata @> '{"channel": "mobile"}';

-- nested paths
SELECT metadata #>> '{shipping,city}' FROM orders WHERE id = 1;

CREATE INDEX orders_metadata_idx ON orders USING gin (metadata);
A GIN index on a jsonb column makes containment queries fast. Without it, every such query is a sequential scan.

Arrays, enums, ranges and domains

sql
-- Arrays
ALTER TABLE orders ADD COLUMN tags text[] NOT NULL DEFAULT '{}';
SELECT * FROM orders WHERE tags @> ARRAY['priority'];
SELECT * FROM orders WHERE 'gift' = ANY(tags);

-- Enums: compact storage, ordered, but adding values needs DDL
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'cancelled');

-- Ranges: a first-class interval type with proper operators
CREATE TABLE room_bookings (
    room_id  bigint NOT NULL,
    period   tstzrange NOT NULL,
    EXCLUDE USING gist (room_id WITH =, period WITH &&)
);
That EXCLUDE constraint makes double-booking a room physically impossible. No application check, no race condition, no retry logic.

The exclusion constraint above deserves a second look. Preventing overlapping bookings in application code means reading, checking, then writing - and two concurrent requests can both pass the check before either writes. The database constraint has no such window. This is the general shape of the argument for putting rules in the database: correctness that does not depend on every code path remembering.

sql
-- Domains: a reusable constrained type
CREATE DOMAIN email AS text
    CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$');

CREATE TABLE subscribers (
    id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    address  email NOT NULL UNIQUE
);
TypeUse it for
jsonbVariable, semi-structured data you sometimes query
text[]Small unordered sets - tags, flags, roles
tstzrange, daterange, numrangePeriods and intervals, with overlap operators
inet, cidr, macaddrNetwork addresses, with real containment operators
uuidIdentifiers - use uuidv7() in 18 for index locality
numericMoney and anything where floating point rounding is unacceptable
tsvectorFull-text search documents
intervalDurations - now() + interval '30 days' just works

Queries you should know

Upsert

sql
INSERT INTO customers (email, full_name)
VALUES ('naiem@example.com', 'Naiem Inahid')
ON CONFLICT (email) DO UPDATE
    SET full_name = EXCLUDED.full_name
RETURNING id, (xmax = 0) AS was_inserted;
EXCLUDED is the row you tried to insert. The xmax = 0 trick tells you whether the row was created or updated.

RETURNING, which saves a round trip

sql
-- Standard: get the generated id back with the insert
INSERT INTO orders (customer_id, total_cents)
VALUES (1, 4999)
RETURNING id, placed_at;

-- PostgreSQL 18: see the before and after values
UPDATE orders SET status = 'paid' WHERE id = 1
RETURNING OLD.status AS previous, NEW.status AS current;

Common table expressions

sql
WITH monthly AS (
    SELECT date_trunc('month', placed_at) AS month,
           sum(total_cents)               AS revenue_cents
    FROM orders
    WHERE status = 'paid'
    GROUP BY 1
)
SELECT month,
       revenue_cents / 100.0 AS revenue,
       lag(revenue_cents) OVER (ORDER BY month) / 100.0 AS previous
FROM monthly
ORDER BY month DESC;

CTEs also recurse, which is how you query hierarchies - org charts, category trees, threaded comments - without pulling everything into the application and looping.

sql
WITH RECURSIVE subtree AS (
    SELECT id, parent_id, name, 1 AS depth
    FROM categories
    WHERE id = 3

    UNION ALL

    SELECT c.id, c.parent_id, c.name, s.depth + 1
    FROM categories c
    JOIN subtree s ON c.parent_id = s.id
)
SELECT repeat('  ', depth - 1) || name AS tree FROM subtree ORDER BY depth;
The anchor query runs once; the recursive half runs until it returns no rows.

Window functions

Window functions compute across a set of rows while keeping every row in the output - rankings, running totals, comparisons to the previous row. Anything you were planning to do with a loop in application code is probably a window function.

sql
SELECT
    customer_id,
    placed_at,
    total_cents,
    row_number() OVER w                       AS order_number,
    sum(total_cents) OVER w                   AS running_total,
    total_cents - lag(total_cents) OVER w     AS change_from_previous,
    avg(total_cents) OVER (
        PARTITION BY customer_id
        ORDER BY placed_at
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    )                                         AS moving_average
FROM orders
WINDOW w AS (PARTITION BY customer_id ORDER BY placed_at);
The WINDOW clause names a window once and reuses it, which keeps long queries readable.

LATERAL joins

LATERAL lets a subquery reference columns from the row to its left. The classic use is top-N-per-group - the three most recent orders for every customer - which is awkward any other way.

sql
SELECT c.full_name, recent.id, recent.placed_at, recent.total_cents
FROM customers c
CROSS JOIN LATERAL (
    SELECT o.id, o.placed_at, o.total_cents
    FROM orders o
    WHERE o.customer_id = c.id
    ORDER BY o.placed_at DESC
    LIMIT 3
) AS recent;
Use LEFT JOIN LATERAL … ON true instead if you want customers with no orders to appear.

Indexes: the part that decides whether it is fast

PostgreSQL has several index types, and most people only ever use one. Knowing when to reach for the others is most of the difference between a database that scales and one that does not.

TypeGood forTypical use
B-treeEquality and range on scalar valuesThe default. Primary keys, foreign keys, dates, sorting
GINValues containing multiple itemsjsonb, arrays, full-text search
GiSTGeometric, range and nearest-neighbour queriesPostGIS, exclusion constraints, ranges
BRINVery large tables with naturally ordered dataAppend-only event logs by timestamp - tiny index, huge table
HashEquality onlyRarely worth it; B-tree usually wins
SP-GiSTNon-balanced structures, partitioned searchText prefix search, quadtrees

Partial, expression and covering indexes

sql
-- Partial: index only the rows you actually query
CREATE INDEX orders_pending_idx ON orders (placed_at)
    WHERE status = 'pending';

-- Expression: index the result of a function
CREATE INDEX customers_email_lower_idx ON customers (lower(email));
-- now this uses the index:
SELECT * FROM customers WHERE lower(email) = 'naiem@example.com';

-- Covering: carry extra columns so the query never touches the table
CREATE INDEX orders_customer_idx ON orders (customer_id) INCLUDE (total_cents);

-- Build without locking writes - always use this in production
CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status);

Reading EXPLAIN

EXPLAIN shows the plan the planner chose. EXPLAIN ANALYZE runs the query and shows what actually happened, which is the one you want. Add BUFFERS and you can see how much of the work came from cache.

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT c.full_name, count(*)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.placed_at > now() - interval '30 days'
GROUP BY c.full_name;
EXPLAIN ANALYZE executes the query. Wrap it in a transaction you roll back if the statement writes.
  • Seq Scan on a large table with a selective filter - usually a missing index, sometimes stale statistics.
  • Rows estimate far from actual - the planner is working from bad information. Run ANALYZE, and consider CREATE STATISTICS for correlated columns.
  • Nested Loop with a high row count on the inner side - often a join that should be a hash join; check whether the planner underestimated.
  • External merge Disk in a Sort node - the sort spilled to disk. Raise work_mem for that session or query.
  • Heap Fetches high on an Index Only Scan - the visibility map is stale; the table needs a vacuum.
  • A filter removing most rows after the scan - the predicate could not use the index. An expression or partial index may fix it.

Paste the plan into explain.dalibo.com or explain.depesz.com when it gets long. Both render the tree and highlight where the time went, which is much faster than reading nested indentation by eye.

How far one PostgreSQL goes

Here is the practical case for consolidation. Each of these is a category people routinely add a separate system for, and each is a few lines of SQL away.

sql
ALTER TABLE articles ADD COLUMN search tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('english', coalesce(title, '')),   'A') ||
        setweight(to_tsvector('english', coalesce(body,  '')),   'B')
    ) STORED;

CREATE INDEX articles_search_idx ON articles USING gin (search);

SELECT id, title, ts_rank(search, query) AS rank
FROM articles, websearch_to_tsquery('english', 'postgres index tuning') AS query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;
websearch_to_tsquery accepts the syntax users already type - quoted phrases, or, and -exclusions. The weights make title matches outrank body matches.

This will not replace Elasticsearch at large scale or with complex relevance tuning. It comfortably handles search over hundreds of thousands of documents, and it stays consistent with your data because it *is* your data - no sync job, no reindex lag, no second system to secure.

A job queue

sql
CREATE TABLE jobs (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload      jsonb  NOT NULL,
    run_after    timestamptz NOT NULL DEFAULT now(),
    attempts     integer NOT NULL DEFAULT 0,
    locked_until timestamptz
);

CREATE INDEX jobs_ready_idx ON jobs (run_after)
    WHERE locked_until IS NULL;

-- A worker claims one job atomically
UPDATE jobs SET
    locked_until = now() + interval '5 minutes',
    attempts     = attempts + 1
WHERE id = (
    SELECT id FROM jobs
    WHERE run_after <= now()
      AND (locked_until IS NULL OR locked_until < now())
    ORDER BY run_after
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
RETURNING id, payload;
FOR UPDATE SKIP LOCKED is the whole trick - each worker skips rows another worker has locked, so ten workers never collide.

Jobs enqueued inside the same transaction as your business data cannot get out of sync with it. That property alone is worth a lot: with an external queue, the classic bug is a job that fires for a row that was rolled back.

Pub/sub notifications

sql
-- Listener
LISTEN order_paid;

-- Publisher, from a trigger or application code
SELECT pg_notify('order_paid', json_build_object('order_id', 42)::text);
Fire-and-forget: notifications are not queued for disconnected listeners. Fine for cache invalidation and worker wake-ups, not a replacement for a durable queue.

Vector search for AI features

sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    doc_id     bigint NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    content    text   NOT NULL,
    embedding  vector(1536)
);

CREATE INDEX chunks_embedding_idx ON chunks
    USING hnsw (embedding vector_cosine_ops);

-- <=> is cosine distance; <-> is L2, <#> is inner product
SELECT id, content, 1 - (embedding <=> $1) AS similarity
FROM chunks
WHERE doc_id = ANY($2)          -- ordinary SQL filtering, in the same query
ORDER BY embedding <=> $1
LIMIT 10;
The WHERE clause is the point. A dedicated vector database makes combining similarity with relational filters awkward; here it is one query with a join available if you need one.

Two practical notes on pgvector. HNSW is the index you want in almost all cases - better recall and latency than IVFFlat, and it can be built on an empty table. And the vector type caps HNSW indexes at 2,000 dimensions, so for larger embeddings use halfvec, which also roughly halves storage with modest recall loss.

Analytics on the operational database

sql
-- A materialised view for a dashboard
CREATE MATERIALIZED VIEW daily_revenue AS
SELECT date_trunc('day', placed_at)::date AS day,
       count(*)                           AS order_count,
       sum(total_cents)                   AS revenue_cents
FROM orders
WHERE status = 'paid'
GROUP BY 1;

CREATE UNIQUE INDEX daily_revenue_day_idx ON daily_revenue (day);

-- The unique index is what allows this to refresh without blocking readers
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;

So when do you actually need the other system?

Instead ofPostgreSQL gives youAdd the separate system when
RedisUNLOGGED tables, LISTEN/NOTIFY, advisory locksYou need sub-millisecond reads at very high throughput, or a genuinely separate failure domain
Elasticsearchtsvector with GIN, ranking, weighting, pg_trgm fuzzy matchingComplex relevance tuning, faceted aggregation at scale, or tens of millions of documents
RabbitMQ or SQSSKIP LOCKED queues in the same transaction as your dataCross-service messaging, fan-out topologies, or very high sustained throughput
MongoDBjsonb with GIN indexes and full transactionsYou genuinely have no schema and need horizontal write scaling
A vector databasepgvector with HNSW and relational filteringAbove roughly tens of millions of vectors, or you need distributed vector search
A data warehouseMaterialised views, window functions, partitioningAnalytical queries start competing with production traffic for resources
The pattern: start with one database, and let a real measured constraint push you to a second one. Not the reverse.

Extensions worth knowing

ExtensionWhat it adds
pg_stat_statementsAggregated statistics per normalised query - the first thing to install anywhere
pgvectorVector types, distance operators, HNSW and IVFFlat indexes
PostGISGeospatial types, indexes and hundreds of functions; effectively the industry standard for GIS
pg_trgmTrigram similarity - fuzzy matching, LIKE '%…%' acceleration, typo tolerance
TimescaleDBTime-series partitioning, continuous aggregates, compression
pg_partmanAutomated creation and retention of table partitions
postgres_fdwQuery another PostgreSQL database as if its tables were local
pgcryptoHashing and encryption functions in the database
citextCase-insensitive text, so you stop writing lower() everywhere
hypopgHypothetical indexes - test whether an index would help before building it
sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- The 20 queries consuming the most total time
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;
Total time, not mean. A 5 ms query called two million times costs far more than a 3-second report run once a day.

Transactions, MVCC and isolation

PostgreSQL uses multiversion concurrency control. Writers create new row versions rather than overwriting old ones, so readers never block writers and writers never block readers. Each transaction sees a consistent snapshot.

The cost of that design is dead rows - old versions that no longer belong to any visible snapshot. VACUUM reclaims them, autovacuum runs it for you, and almost every mysterious PostgreSQL performance problem eventually traces back to vacuum not keeping up.

Isolation levelPreventsUse it when
Read Committed (default)Dirty readsAlmost always - each statement sees a fresh snapshot
Repeatable ReadNon-repeatable reads, phantom readsA multi-statement transaction needs one consistent view
SerializableAll anomalies, as if transactions ran one at a timeCorrectness matters more than throughput - but handle serialisation failures and retry
PostgreSQL's Repeatable Read is stronger than the SQL standard requires; it already blocks phantom reads.
sql
BEGIN;
  -- Row-level lock, held until commit
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- Advisory locks: application-level mutual exclusion, no table needed
SELECT pg_try_advisory_lock(12345);
-- … do the work only one process should do …
SELECT pg_advisory_unlock(12345);
Always lock rows in a consistent order across your codebase. Two transactions locking the same two rows in opposite orders is the textbook deadlock.

Running it in production

Connection pooling is not optional

PostgreSQL uses one operating system process per connection. That model is robust and it does not scale to thousands of connections - each one costs memory, and the context switching gets expensive well before you run out of RAM.

Put PgBouncer or pgcat in front. Transaction pooling mode gives the best multiplexing, at the cost of session-level features: prepared statements need care, SET does not persist across statements, and LISTEN/NOTIFY will not work through the pooler. Serverless environments make this worse rather than better, because each cold function instance wants its own connection.

Settings that matter more than the rest

postgresql.conf
shared_buffers = 25% of RAM        -- PostgreSQL's own cache
effective_cache_size = 50-75% RAM  -- a hint to the planner, not an allocation
work_mem = 16MB                    -- per sort or hash node, per query - multiply carefully
maintenance_work_mem = 512MB       -- vacuum and index builds
max_connections = 100              -- keep it low; use a pooler for the rest
random_page_cost = 1.1             -- for SSDs; the 4.0 default assumes spinning disks
wal_compression = on
log_min_duration_statement = 500   -- log anything slower than 500ms
work_mem is per operation, not per query. A complex query with several sorts on fifty connections can multiply this into swap.

Backups you have actually tested

bash
# Logical dump - portable, restorable per object, slow on large databases
pg_dump -Fc shop > shop.dump
pg_restore -d shop_restored shop.dump

# Physical base backup - the basis of point-in-time recovery
pg_basebackup -D /backup/base -Ft -z -P

# Check what the database itself thinks about vacuum health
SELECT relname, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 10;
For real deployments use pgBackRest or Barman rather than hand-rolled scripts. Both handle retention, incremental backups and point-in-time recovery.

Replication and high availability

  • Streaming replication ships the write-ahead log to replicas. Asynchronous by default; synchronous if you need zero data loss and can accept the latency.
  • Read replicas take reporting and analytics load off the primary. They lag, so route anything read-after-write back to the primary.
  • Logical replication replicates selected tables, works across major versions, and is the basis for near-zero-downtime upgrades.
  • Failover is not built in. Patroni is the usual answer for self-managed clusters; managed providers handle it for you.

Mistakes that keep recurring

MistakeWhat it causesFix
timestamp instead of timestamptzAmbiguous times across zones and DST changesUse timestamptz everywhere; migrate early
SELECT * in application codeBreaks when a column is added; fetches data you discardName your columns
Indexing every columnSlow writes, bloat, wasted memoryIndex for the queries you actually run; check pg_stat_user_indexes for unused ones
No index on a foreign key columnSlow joins and slow cascading deletesPostgreSQL indexes the referenced side, not the referencing one - add it yourself
OFFSET for deep paginationGets linearly slower; page 5,000 scans 100,000 rowsKeyset pagination: WHERE (placed_at, id) < ($1, $2) ORDER BY … LIMIT 20
Long-running idle transactionsBlocks vacuum, causes bloat across the whole databaseWatch pg_stat_activity for idle in transaction; set idle_in_transaction_session_timeout
float for moneyRounding errors in financial totalsInteger cents, or numeric
Application-side uniqueness checksRace conditions under concurrencyA UNIQUE constraint - the database has no race window
Ignoring autovacuum warningsTable bloat, then transaction ID wraparound riskMonitor n_dead_tup; tune autovacuum per table on hot tables
ALTER TABLE in a migration without thinking about locksTable locked, requests queue, brief outageCheck the lock level required; set lock_timeout in migrations
sql
-- Keyset pagination: constant time regardless of how deep you go
SELECT id, placed_at, total_cents
FROM orders
WHERE (placed_at, id) < ($1, $2)
ORDER BY placed_at DESC, id DESC
LIMIT 20;
Pass the last row's placed_at and id as the cursor. This needs an index on (placed_at DESC, id DESC) to pay off.

PostgreSQL against the alternatives

OptionIt wins onPostgreSQL wins onPick it when
MySQL / MariaDBReplication familiarity, some read-heavy simple workloadsType system, extensions, jsonb, window functions, constraint depthYou have existing MySQL expertise and simple relational needs
SQLiteZero operations, embedded, single fileConcurrency, network access, extensions, scaleThe database is local to one process or device
MongoDBSchema-free ergonomics, horizontal write scalingTransactions, joins, constraints, SQL, jsonb covers most document needsYou genuinely need distributed writes and no schema
ClickHouse / DuckDBAnalytical query speed on very large datasetsTransactional workloads, mixed read/write, one system for everythingAnalytics is the primary workload, not a side of it
Managed Aurora / Cloud SQL / NeonOperations, failover, scaling handled for youPortability, cost control, no version lagYou would rather pay than run it - usually the right call for small teams
Managed PostgreSQL is still PostgreSQL. Choosing a provider is an operations decision, not a database decision.

A learning path that works

Week one: fluency

  1. Install PostgreSQL locally or run the Docker image, and use psql rather than a GUI while you learn.
  2. Work through the official Tutorial - it is short and better than most paid courses.
  3. Build a small schema of your own: three or four tables with real foreign keys and check constraints.
  4. Practise joins until inner, left, and the difference between WHERE and ON filtering are automatic.
  5. Learn ten psql backslash commands. \d, \x, \timing and \e will carry you a long way.

Month one: the parts that make it PostgreSQL

  1. Aggregations and GROUP BY, then window functions - build the same report both ways to feel the difference.
  2. CTEs, including a recursive one over a category or org-chart table.
  3. jsonb: store a document, query it with ->> and @>, then add a GIN index and compare EXPLAIN output before and after.
  4. Indexes: create one, force a sequential scan with SET enable_indexscan = off, and compare the plans.
  5. Transactions and isolation: open two psql sessions and make them block each other on purpose. Nothing teaches locking faster.
  6. EXPLAIN ANALYZE on every query you write until reading a plan stops feeling like work.

Month three: production judgement

  1. pg_stat_statements - find your slowest queries and fix the top three.
  2. Vacuum and bloat: understand why they happen, and read pg_stat_user_tables regularly.
  3. Connection pooling with PgBouncer, including what transaction mode takes away.
  4. Backup and restore, with an actual timed restore into a scratch database.
  5. Replication: set up a streaming replica locally and promote it.
  6. Partitioning: take a large table and partition it by range, then look at how the plans change.
  7. One extension in depth - pgvector, PostGIS or TimescaleDB, whichever your work needs.

Resources worth the time

  • The official documentation - genuinely one of the best technical manuals in software. Read it directly rather than searching for blog posts.
  • Use The Index, Luke - the clearest explanation of SQL indexing anywhere, and free.
  • PostgreSQL Exercises - practice problems with answers, in the browser.
  • explain.dalibo.com - paste a plan, see where the time went.
  • Planet PostgreSQL - aggregated blogs from the people who build and operate it.
  • \h CREATE INDEX in psql - inline help for every SQL command, without leaving the terminal.

Verdict

PostgreSQL is the best default database for the large majority of applications, and the reason is not any single feature. It is that the type system, the constraint system, the extension mechanism and the query planner are all good enough that you can push real correctness into the database and delay every other system you thought you needed.

It asks something in return. Vacuum, connection pooling, index design and query plans are not optional knowledge - they are the operational surface, and teams that ignore them hit a wall around the point their data gets interesting. That learning is portable, though. It stays true across versions, across managed providers, and across jobs.

Start with one PostgreSQL. Add a second system only when a measured constraint forces you to, and be able to name the constraint.

Our default architecture recommendation

If you are learning it, spend the time on EXPLAIN and on the type system rather than on ORMs - those two will change how you design schemas, and the rest follows. If you are already running it, install pg_stat_statements, look at your top three queries by total time, and test a restore this week. That is usually where the wins are.

Sources

Back to Blog
Share:

Related Posts