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
| Concept | What it is | Note |
|---|---|---|
| Table | A structured collection of rows with a fixed set of typed columns | Created with CREATE TABLE |
| Join | Combining rows from two or more tables via a matching condition | INNER/LEFT/RIGHT/FULL behave differently on non-matches |
| Transaction | A group of statements that succeed or fail together | BEGIN / COMMIT / ROLLBACK |
| Role | An account with login and/or permission attributes | Granted specific privileges via GRANT |
| Index | A separate structure that speeds up lookups on a column | Deep 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.
| Command | Description | Copy |
|---|---|---|
CREATE DATABASE app; | Create a new database. | |
\c app | Connect to a different database (psql meta-command). | |
\l | List 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 users | Describe a table's columns, indexes, and constraints (psql). |
| Command | Description | Copy |
|---|---|---|
SELECT * FROM users WHERE email = 'ada@example.com'; | Filter rows by a condition. | |
SELECT * FROM users ORDER BY created_at DESC LIMIT 10; | Sort and limit results. | |
SELECT o.id, u.email FROM orders o INNER JOIN users u ON u.id = o.user_id; | Combine only matching rows from two tables. | |
SELECT u.email, o.id FROM users u LEFT JOIN orders o ON o.user_id = u.id; | Keep every row from the left table, even without a match on the right. | |
SELECT DISTINCT country FROM users; | Return only unique values. |
| Command | Description | Copy |
|---|---|---|
SELECT COUNT(*) FROM users; | Count rows. | |
SELECT country, COUNT(*) FROM users GROUP BY country; | Count rows per group. | |
SELECT country, COUNT(*) FROM users GROUP BY country HAVING COUNT(*) > 100; | Filter groups after aggregation. | |
SELECT AVG(total), SUM(total) FROM orders; | Compute an average and a sum across all matching rows. |
| Command | Description | Copy |
|---|---|---|
SELECT id, total, ROW_NUMBER() OVER (ORDER BY total DESC) FROM orders; | Number rows by rank, without collapsing them the way GROUP BY would. | |
SELECT id, total, RANK() OVER (PARTITION BY user_id ORDER BY total DESC) FROM orders; | Rank rows within each partition (group), keeping every row in the output. | |
SELECT id, total, SUM(total) OVER (PARTITION BY user_id) AS user_total FROM orders; | Show a group total alongside every individual row. |
| Command | Description | Copy |
|---|---|---|
BEGIN; | Start a transaction block. | |
COMMIT; | Persist every change made inside the transaction. | |
ROLLBACK; | Discard every change made inside the transaction. | |
SAVEPOINT before_update; | Mark a point to roll back to, without discarding the whole transaction. | |
ROLLBACK TO SAVEPOINT before_update; | Undo changes back to a specific savepoint. |
| Command | Description | Copy |
|---|---|---|
CREATE ROLE app_user LOGIN; | Create a role that can log in, with no password set yet. Set one interactively afterward with psql's \password app_user, which never puts the cleartext value in a command, shell history, or server log. | |
GRANT SELECT, INSERT ON users TO app_user; | Grant specific privileges on a table to a role. | |
REVOKE INSERT ON users FROM app_user; | Remove a previously granted privilege. | |
\du | List roles and their attributes (psql meta-command). |
| Command | Description | Copy |
|---|---|---|
CREATE INDEX idx_users_email ON users (email); | Create a standard B-tree index on a column. | |
CREATE INDEX idx_orders_user_id ON orders (user_id); | Index a foreign key column; Postgres doesn't create one automatically, and joins/lookups on orders.user_id benefit from it directly. | |
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'ada@example.com'; | Show the real query plan and actual execution time. | |
\di | List indexes (psql meta-command). |
Syntax
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
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 JOINwhen the intent was "every row from the left table, matched or not," silently dropping rows with no match instead of surfacing them withNULLs viaLEFT JOIN. - Filtering on an aggregate with
WHEREinstead ofHAVING;WHEREruns before grouping happens, so it can't reference the aggregated value at all. - Running several related
UPDATE/INSERTstatements 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
WHEREclause 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 ANALYZEshows the real plan and real timing, not just the estimated oneEXPLAINalone 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/JOINperformance is usually an indexing problem before it's a query-rewrite problem. - Long-running transactions hold locks and prevent
VACUUMfrom 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/COMMITtransaction. - Grant the narrowest privilege a role actually needs (
SELECTonly, a specific table, notGRANT 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 ANALYZEbefore 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.