Cloud Tech by Victor
DatabasesBeginner

PostgreSQL Fundamentals

How tables, joins, aggregates, window functions, and transactions fit together in everyday PostgreSQL work, from the actual internals of a single index (covered in PostgreSQL Indexes) to the SQL you write day to day.

Updated 2026-07-247 min read

Overview

Day-to-day PostgreSQL work is mostly a small set of composable building blocks: defining tables, querying and joining them, aggregating results, wrapping related changes in transactions, and controlling who can do what through roles and grants. This topic covers that everyday layer; the actual internals of how a single B-tree index is structured and when it does or doesn't get used live in the separate, deeper PostgreSQL Indexes topic; this one's indexing section stays at the "here's the command you'd reach for" level, not the storage-engine internals.

Quick Reference

ConceptWhat it isNote
TableA structured collection of rows with a fixed set of typed columnsCreated with CREATE TABLE
JoinCombining rows from two or more tables via a matching conditionINNER/LEFT/RIGHT/FULL behave differently on non-matches
TransactionA group of statements that succeed or fail togetherBEGIN / COMMIT / ROLLBACK
RoleAn account with login and/or permission attributesGranted specific privileges via GRANT
IndexA separate structure that speeds up lookups on a columnDeep dive: PostgreSQL Indexes

The commands below are grouped by task, not an exhaustive reference (see the official PostgreSQL SQL command reference for every clause), but the set that covers nearly all everyday PostgreSQL work.

CommandDescriptionCopy
CREATE DATABASE app;Create a new database.
\c appConnect to a different database (psql meta-command).
\lList all databases (psql meta-command).
CREATE TABLE users (id serial PRIMARY KEY, email text UNIQUE NOT NULL);Create a table with a primary key and a unique constraint.
ALTER TABLE users ADD COLUMN created_at timestamptz DEFAULT now();Add a column with a default value.
\d usersDescribe a table's columns, indexes, and constraints (psql).

Syntax

sql
SELECT
  u.email,
  COUNT(o.id) AS order_count,
  SUM(o.total) AS lifetime_value
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.email
HAVING COUNT(o.id) > 0
ORDER BY lifetime_value DESC
LIMIT 10;

Examples

sql
BEGIN;

DO $$
DECLARE
  affected int;
BEGIN
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  GET DIAGNOSTICS affected = ROW_COUNT;
  -- a no-op match isn't a SQL error, so PostgreSQL won't catch a
  -- mistyped id on its own; raising here aborts the transaction
  IF affected <> 1 THEN
    RAISE EXCEPTION 'expected 1 row updated for id=1, got %', affected;
  END IF;

  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
  GET DIAGNOSTICS affected = ROW_COUNT;
  IF affected <> 1 THEN
    RAISE EXCEPTION 'expected 1 row updated for id=2, got %', affected;
  END IF;
END $$;

-- reached only if both row-count checks above passed; an EXCEPTION
-- above leaves the transaction aborted, making COMMIT a no-op rollback
COMMIT;
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;

Show the actual query plan PostgreSQL chose and how long each step really took, the first thing to check when a query is slower than expected.

EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;

Visual Diagram

Common Mistakes

  • Using INNER JOIN when the intent was "every row from the left table, matched or not," silently dropping rows with no match instead of surfacing them with NULLs via LEFT JOIN.
  • Filtering on an aggregate with WHERE instead of HAVING; WHERE runs before grouping happens, so it can't reference the aggregated value at all.
  • Running several related UPDATE/INSERT statements outside an explicit transaction, leaving the database in a partially-applied state if one of them fails.
  • Granting broad privileges (GRANT ALL) to an application role instead of exactly what it needs, widening the blast radius of a compromised credential or a buggy query.
  • Assuming a WHERE clause automatically uses an index, a function applied to the indexed column (WHERE lower(email) = ...), a leading wildcard (LIKE '%term'), or a type mismatch can all silently force a full table scan.

Performance

  • EXPLAIN ANALYZE shows the real plan and real timing, not just the estimated one EXPLAIN alone shows; always confirm a slow query's actual bottleneck before adding an index speculatively.
  • Aggregating over a large table without a supporting index on the filter/join columns forces a sequential scan; GROUP BY/JOIN performance is usually an indexing problem before it's a query-rewrite problem.
  • Long-running transactions hold locks and prevent VACUUM from reclaiming dead row versions, keeping transactions as short as the logical unit of work actually requires matters at scale, not just for correctness.

Best Practices

  • Wrap any multi-statement operation that needs to succeed or fail as a unit in an explicit BEGIN/COMMIT transaction.
  • Grant the narrowest privilege a role actually needs (SELECT only, a specific table, not GRANT ALL ON ALL TABLES), and create separate roles per application rather than sharing one superuser credential.
  • Reach for a window function instead of a self-join or a correlated subquery when you need a per-row calculation that references other rows in the same result set.
  • Run EXPLAIN ANALYZE before adding an index speculatively; confirm the query is actually scanning more rows than it needs to.
  • Keep transactions as short as the logical unit of work requires; a transaction left open while waiting on unrelated application logic holds locks longer than necessary.

Interview questions

What is the difference between `INNER JOIN` and `LEFT JOIN`?

`INNER JOIN` returns only rows where the join condition matches in both tables, a row from either side with no match is dropped entirely from the result. `LEFT JOIN` returns every row from the left table regardless of whether a match exists on the right, filling in `NULL` for the right table's columns when there isn't one. Reaching for `INNER JOIN` when you actually need "every user, even ones with zero orders" silently drops exactly the rows you wanted to see, which is why an unexpectedly short result set is one of the most common join bugs.

What is the difference between GROUP BY aggregation and a window function like `ROW_NUMBER() OVER (...)`?

`GROUP BY` collapses every row in a group into a single output row per group, you lose access to the individual rows once you've aggregated. A window function computes its result (a rank, a running total, a row number) per row while keeping every individual row in the output, `PARTITION BY` defines the grouping for that calculation, but nothing is collapsed. This is why "give me each order plus that customer's running total" needs a window function, while "give me one row per customer with their total" is a plain `GROUP BY`.

Why does wrapping several statements in `BEGIN`/`COMMIT` matter, even for a multi-step operation that's logically one action?

Without an explicit transaction, PostgreSQL commits each statement independently the moment it succeeds; if a multi-step operation (debit one account, credit another) fails halfway through, the database is left in an inconsistent, partially-applied state with no way to undo the completed step. Wrapping the statements in `BEGIN`/`COMMIT` groups them so they can be committed or rolled back together, but that alone isn't automatic protection against every failure mode: PostgreSQL only aborts a transaction on its own when a statement raises an actual SQL error, an `UPDATE` that runs successfully but matches zero rows (a mistyped account id, for instance) is not an error at all, and would otherwise be committed as if the transfer had actually happened. Making the transaction genuinely safe means checking that each `UPDATE` affected exactly the one row expected and issuing an explicit `ROLLBACK` if either check fails, only issuing `COMMIT` once both checks pass.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement