Skip to content

PostgreSQL

The most feature-rich open-source relational database and the default choice for data platforms. Postgres adds powerful types (JSONB, arrays, ranges), full-text search, and mature analytics features on top of standard SQL.

Table of Contents

Installation / Setup

# macOS:  brew install postgresql@16
# Ubuntu: sudo apt install postgresql
# Docker: docker run -d -e POSTGRES_PASSWORD=pass -p 5432:5432 postgres:16
psql -h localhost -U postgres -d mydb     # CLI
import psycopg2                            # classic driver
import pandas as pd
con = psycopg2.connect(host="localhost", dbname="mydb", user="postgres", password="pass")
pd.read_sql("SELECT * FROM orders", con)
psql command Meaning
\l list databases
\c dbname connect to database
\dt list tables
\d table describe table (columns, indexes, FKs)
\i file.sql run a SQL file
\x expanded output (wide rows)
\q quit

Core Syntax

Common data types

Type Use
INTEGER, BIGINT, SMALLINT integers
NUMERIC(p, s) exact decimal (money!)
REAL, DOUBLE PRECISION floats
TEXT, VARCHAR(n) strings
BOOLEAN true/false
DATE, TIME, TIMESTAMP, TIMESTAMPTZ time (always prefer timestamptz)
INTERVAL duration
UUID globally unique ids
JSONB binary JSON — queryable, indexable
ARRAY native arrays (TEXT[])
SERIAL / GENERATED ALWAYS AS IDENTITY auto-increment
ENUM fixed value lists

Dialect quick wins

-- String & case
SELECT name ILIKE '%ada%' FROM customers;       -- case-insensitive LIKE
SELECT SPLIT_PART(email, '@', 1) FROM customers;

-- Date math the Postgres way
SELECT NOW(), CURRENT_DATE,
       created_at + INTERVAL '30 days',
       DATE_TRUNC('week', created_at),
       EXTRACT(ISODOW FROM created_at);         -- 1=Mon .. 7=Sun

-- GREATEST/LEAST, DISTINCT ON, FILTER
SELECT GREATEST(a, b), LEAST(a, b) FROM t;

SELECT DISTINCT ON (customer_id) *              -- first row per key (no window needed)
FROM orders ORDER BY customer_id, created_at DESC;

SELECT department,
       COUNT(*) FILTER (WHERE salary > 100000) AS highly_paid,
       COUNT(*) AS total
FROM employees GROUP BY department;

Common Operations

JSONB (event/feature data without a schema)

CREATE TABLE events (id BIGINT, payload JSONB);

INSERT INTO events VALUES (1, '{"user_id": 42, "action": "click", "meta": {"page": "pricing"}}');

-- Operators
SELECT payload->>'user_id'            AS user_id,      -- -> text  |  -> json
       payload->'meta'->>'page'       AS page
FROM events;

-- Query inside JSON
SELECT * FROM events
WHERE payload->>'action' = 'click'
  AND payload @> '{"meta": {"page": "pricing"}}';

-- Index JSON fields
CREATE INDEX idx_events_action ON events ((payload->>'action'));
CREATE INDEX idx_events_gin ON events USING GIN (payload);
-- tsvector: precomputed search tokens; tsquery: the query
SELECT * FROM articles
WHERE to_tsvector('english', body) @@ to_tsquery('machine & learning');

CREATE INDEX idx_articles_fts ON articles
    USING GIN (to_tsvector('english', body));

Arrays

SELECT ARRAY[1, 2, 3], '{4,5,6}'::int[];
SELECT tags[1] FROM posts;                        -- 1-indexed
SELECT * FROM posts WHERE 'python' = ANY(tags);
SELECT unnest(tags) AS tag FROM posts;            -- expand to rows

Practical Examples

1. Time-series rollup to daily aggregates

WITH daily AS (
    SELECT user_id, DATE_TRUNC('day', created_at) AS day,
           COUNT(*) AS events
    FROM events GROUP BY 1, 2
)
SELECT day, AVG(events) AS avg_events_per_user,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY events) AS median_events
FROM daily GROUP BY day ORDER BY day;

2. Retention-style first/last with DISTINCT ON

SELECT DISTINCT ON (user_id)
       user_id, created_at AS first_seen
FROM events
ORDER BY user_id, created_at;

3. EXPLAIN a slow query

EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, SUM(total) FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY customer_id;
-- look for: Seq Scan on big table (needs index), Hash vs Nested Loop,
-- actual vs estimated rows, and total time

4. Loading CSV with COPY (fastest bulk load)

COPY orders FROM '/tmp/orders.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ',');

COPY (SELECT * FROM orders WHERE status = 'paid')
TO '/tmp/paid.csv' WITH (FORMAT csv, HEADER true);

Common Patterns

-- Upsert (insert or update on conflict)
INSERT INTO users (id, email, name)
VALUES (1, 'a@x.com', 'Ada')
ON CONFLICT (email)
DO UPDATE SET name = EXCLUDED.name;

-- Keep a denormalized summary fresh with a trigger-safe view or materialized view
CREATE MATERIALIZED VIEW mv_monthly AS
    SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue
    FROM orders GROUP BY 1;
REFRESH MATERIALIZED VIEW mv_monthly;           -- re-run when needed

-- Efficient pagination (keyset, not OFFSET)
SELECT * FROM orders
WHERE (created_at, id) < ('2024-05-01', 1000)   -- last seen cursor
ORDER BY created_at DESC, id DESC LIMIT 100;

Best Practices

  • Use TIMESTAMPTZ everywhere; store UTC, convert at display time.
  • Prefer NUMERIC for money, DOUBLE PRECISION for measurements.
  • Index hot paths: join keys, WHERE columns, and JSONB accessors with expression indexes.
  • Use EXPLAIN (ANALYZE) before declaring a query slow — guess less, measure.
  • Prefer DISTINCT ON / window functions over self-joins for per-group first/last.
  • Batch with COPY for bulk loads instead of row-by-row INSERTs.
  • JSONB for flexible payloads, but promote hot fields to real columns — JSONB is not a substitute for schema.
  • Always check pg_stat_activity and vacuum/autovacuum status on write-heavy systems.

Common Mistakes

Mistake Consequence Fix
TIMESTAMP instead of TIMESTAMPTZ Wrong times across time zones Use timestamptz
WHERE jsonb_col = '{"a":1}' Never matches (binary form) @>, ->>, or #>> operators
OFFSET pagination on huge tables Slows as pages grow Keyset pagination
Missing index on lower(email) lookups Full scan CREATE INDEX ON t (lower(email))
ON CONFLICT DO NOTHING when you want update Silently drops rows DO UPDATE SET ...
Assuming VARCHAR(n) saves space No benefit over TEXT in Postgres Use TEXT
Updating a JSONB column wholesale Wasted writes Set subfield via jsonb_set

Troubleshooting

Symptom Cause Fix
connection refused Server down / wrong port pg_isready; check port 5432 and pg_hba
relation does not exist Wrong schema/search_path SET search_path TO myschema, public
duplicate key value violates unique constraint Upsert conflict handled wrong Use ON CONFLICT
Slow query suddenly Stale planner stats ANALYZE table; then re-EXPLAIN
out of shared memory Low shared_buffers settings Tune postgresql.conf; restart
Lock waits (pg_locks) Long transaction holding locks Inspect pg_stat_activity; commit/rollback

Official Documentation

Last Updated

2026-09-06 — Initial version.