Quick Start
Want to quickly build applications with relational data and powerful queries? This quick start introduces PostgreSQL’s core concepts through a working application. You’ll learn table creation, relationships, indexes, transactions, and JSON support - the essential 5-30% that powers productive PostgreSQL development.
Prerequisites
Before starting, complete Initial Setup to install PostgreSQL and verify your environment.
You should have:
- PostgreSQL 16+ installed and running
psqlcommand-line client working- Basic understanding of SQL (SELECT, INSERT, UPDATE, DELETE)
- Completed “Your First Database” from Initial Setup
What You’ll Build
You’ll create an e-commerce application with:
- Users with authentication credentials
- Products with categories and pricing
- Orders linking users and products
- Inventory tracking
- JSON metadata for flexible attributes
- Full-text search for product discovery
This covers 5-30% of PostgreSQL features - enough to build real applications while understanding core concepts.
Learning Path
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#0066cc','primaryTextColor':'#000','primaryBorderColor':'#003d7a','lineColor':'#0066cc','secondaryColor':'#e6f2ff','tertiaryColor':'#fff'}}}%%
graph TD
A["Initial Setup<br/>(0-5%)"] --> B["Quick Start<br/>(5-30%)"]
B --> C["By-Example<br/>(30-95%)"]
B --> D["Beginner Tutorial<br/>(0-40%)"]
style A fill:#e6f2ff,stroke:#0066cc,stroke-width:2px,color:#000
style B fill:#0066cc,stroke:#003d7a,stroke-width:3px,color:#fff
style C fill:#e6f2ff,stroke:#0066cc,stroke-width:2px,color:#000
style D fill:#e6f2ff,stroke:#0066cc,stroke-width:2px,color:#000
Learning Objectives
By the end of this quick start, you will:
- Create tables with primary keys, foreign keys, and constraints
- Define relationships between tables (one-to-many, many-to-many)
- Write queries with joins, aggregation, and filtering
- Use indexes to optimize query performance
- Execute transactions for data consistency
- Store JSON data with JSONB for flexible schemas
- Implement full-text search for content discovery
- Apply constraints for data integrity
Connect to Database
Start psql and create a new database for this tutorial.
psql -U postgres
CREATE DATABASE ecommerce;
\c ecommerceYou should see:
You are now connected to database "ecommerce" as user "postgres".Create Tables with Constraints
Define tables with primary keys, data types, and constraints.
Users Table
-- Users table with authentication
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert sample users
INSERT INTO users (username, email, password_hash)
VALUES
('alice', 'alice@example.com', '$2a$10$...'),
('bob', 'bob@example.com', '$2a$10$...'),
('charlie', 'charlie@example.com', '$2a$10$...');
-- Verify insertion
SELECT id, username, email FROM users;Output:
id | username | email
----+----------+---------------------
1 | alice | alice@example.com
2 | bob | bob@example.com
3 | charlie | charlie@example.com
(3 rows)Key concepts:
SERIAL: Auto-incrementing integer (PostgreSQL-specific)PRIMARY KEY: Unique identifier for each rowUNIQUE: Prevents duplicate valuesNOT NULL: Requires value (no NULLs allowed)DEFAULT: Provides default value if not specified
Products Table
-- Products table with categories
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
category VARCHAR(50) NOT NULL,
stock_quantity INTEGER NOT NULL DEFAULT 0 CHECK (stock_quantity >= 0),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert sample products
INSERT INTO products (name, description, price, category, stock_quantity)
VALUES
('Laptop', 'High-performance laptop', 999.99, 'Electronics', 50),
('Mouse', 'Wireless optical mouse', 19.99, 'Electronics', 200),
('Desk Chair', 'Ergonomic office chair', 299.99, 'Furniture', 30),
('Coffee Mug', 'Ceramic coffee mug', 9.99, 'Kitchen', 100);
-- Verify insertion
SELECT id, name, price, category, stock_quantity FROM products;Output:
id | name | price | category | stock_quantity
----+-------------+---------+-------------+----------------
1 | Laptop | 999.99 | Electronics | 50
2 | Mouse | 19.99 | Electronics | 200
3 | Desk Chair | 299.99 | Furniture | 30
4 | Coffee Mug | 9.99 | Kitchen | 100
(4 rows)Key concepts:
NUMERIC(10, 2): Decimal with 10 total digits, 2 after decimal pointTEXT: Variable-length text (no length limit)CHECK: Validates values (e.g., price >= 0)DEFAULT 0: Provides default value
Relationships with Foreign Keys
Link tables together using foreign keys.
Orders Table (One-to-Many)
-- Orders table linking to users
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled')),
total_amount NUMERIC(10, 2) NOT NULL DEFAULT 0.00,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert sample orders
INSERT INTO orders (user_id, status, total_amount)
VALUES
(1, 'delivered', 1019.98), -- Alice
(2, 'processing', 299.99), -- Bob
(1, 'pending', 9.99); -- Alice
-- Verify insertion
SELECT id, user_id, status, total_amount FROM orders;Output:
id | user_id | status | total_amount
----+---------+------------+--------------
1 | 1 | delivered | 1019.98
2 | 2 | processing | 299.99
3 | 1 | pending | 9.99
(3 rows)Foreign key concepts:
REFERENCES users(id): Links to users table primary keyON DELETE CASCADE: Delete orders when user is deletedON DELETE SET NULL: Set foreign key to NULL when parent deletedON DELETE RESTRICT: Prevent parent deletion if children exist (default)
Order Items Table (Many-to-Many through Join Table)
-- Order items (many-to-many: orders ↔ products)
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_at_purchase NUMERIC(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert sample order items
INSERT INTO order_items (order_id, product_id, quantity, price_at_purchase)
VALUES
(1, 1, 1, 999.99), -- Order 1: 1 Laptop
(1, 2, 1, 19.99), -- Order 1: 1 Mouse
(2, 3, 1, 299.99), -- Order 2: 1 Desk Chair
(3, 4, 1, 9.99); -- Order 3: 1 Coffee Mug
-- Verify insertion
SELECT id, order_id, product_id, quantity, price_at_purchase FROM order_items;Output:
id | order_id | product_id | quantity | price_at_purchase
----+----------+------------+----------+-------------------
1 | 1 | 1 | 1 | 999.99
2 | 1 | 2 | 1 | 19.99
3 | 2 | 3 | 1 | 299.99
4 | 3 | 4 | 1 | 9.99
(4 rows)Join table pattern:
- Links two tables in many-to-many relationship
- Contains foreign keys to both parent tables
- Stores relationship-specific data (quantity, price snapshot)
Joins - Querying Related Data
Combine data from multiple tables using joins.
Inner Join - Orders with User Details
-- Get orders with user information
SELECT
o.id AS order_id,
u.username,
u.email,
o.status,
o.total_amount,
o.created_at
FROM orders o
INNER JOIN users u ON o.user_id = u.id
ORDER BY o.created_at DESC;Output:
order_id | username | email | status | total_amount | created_at
----------+----------+--------------------+------------+--------------+------------------------
3 | alice | alice@example.com | pending | 9.99 | 2026-01-29 10:45:30
2 | bob | bob@example.com | processing | 299.99 | 2026-01-29 10:45:29
1 | alice | alice@example.com | delivered | 1019.98 | 2026-01-29 10:45:28
(3 rows)Inner join:
- Returns rows where join condition matches in both tables
- Excludes rows without matches
- Most common join type
Left Join - Products with Order Items
-- Get all products with total quantity sold (including products with no sales)
SELECT
p.id,
p.name,
p.category,
p.price,
COALESCE(SUM(oi.quantity), 0) AS total_sold
FROM products p
LEFT JOIN order_items oi ON p.id = oi.product_id
GROUP BY p.id, p.name, p.category, p.price
ORDER BY total_sold DESC;Output:
id | name | category | price | total_sold
----+-------------+-------------+---------+------------
1 | Laptop | Electronics | 999.99 | 1
2 | Mouse | Electronics | 19.99 | 1
3 | Desk Chair | Furniture | 299.99 | 1
4 | Coffee Mug | Kitchen | 9.99 | 1
(4 rows)Left join:
- Returns all rows from left table (products)
- Includes matching rows from right table (order_items)
- NULL for right table columns when no match
COALESCE(value, 0): Returns 0 if value is NULL
Complex Join - Order Details with Everything
-- Get complete order details (order, user, items, products)
SELECT
o.id AS order_id,
u.username,
o.status,
p.name AS product_name,
oi.quantity,
oi.price_at_purchase,
(oi.quantity * oi.price_at_purchase) AS item_total
FROM orders o
INNER JOIN users u ON o.user_id = u.id
INNER JOIN order_items oi ON o.id = oi.order_id
INNER JOIN products p ON oi.product_id = p.id
ORDER BY o.id, oi.id;Output:
order_id | username | status | product_name | quantity | price_at_purchase | item_total
----------+----------+------------+--------------+----------+-------------------+------------
1 | alice | delivered | Laptop | 1 | 999.99 | 999.99
1 | alice | delivered | Mouse | 1 | 19.99 | 19.99
2 | bob | processing | Desk Chair | 1 | 299.99 | 299.99
3 | alice | pending | Coffee Mug | 1 | 9.99 | 9.99
(4 rows)Aggregation and Grouping
Compute summaries using aggregate functions.
Count Orders by Status
-- Count orders grouped by status
SELECT
status,
COUNT(*) AS order_count
FROM orders
GROUP BY status
ORDER BY order_count DESC;Output:
status | order_count
------------+-------------
delivered | 1
pending | 1
processing | 1
(3 rows)Revenue by Category
-- Calculate total revenue by product category
SELECT
p.category,
COUNT(DISTINCT oi.order_id) AS num_orders,
SUM(oi.quantity) AS total_quantity,
SUM(oi.quantity * oi.price_at_purchase) AS total_revenue
FROM products p
INNER JOIN order_items oi ON p.id = oi.product_id
GROUP BY p.category
ORDER BY total_revenue DESC;Output:
category | num_orders | total_quantity | total_revenue
-------------+------------+----------------+---------------
Electronics | 1 | 2 | 1019.98
Furniture | 1 | 1 | 299.99
Kitchen | 1 | 1 | 9.99
(3 rows)Aggregate functions:
COUNT(*): Count all rowsCOUNT(DISTINCT column): Count unique valuesSUM(column): Sum numeric valuesAVG(column): Average numeric valuesMIN(column),MAX(column): Minimum and maximum values
HAVING Clause - Filter Aggregated Results
-- Find users with more than 1 order
SELECT
u.username,
COUNT(o.id) AS order_count,
SUM(o.total_amount) AS total_spent
FROM users u
INNER JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username
HAVING COUNT(o.id) > 1
ORDER BY total_spent DESC;Output:
username | order_count | total_spent
----------+-------------+-------------
alice | 2 | 1029.97
(1 row)HAVING vs WHERE:
WHERE: Filters rows before aggregationHAVING: Filters groups after aggregationHAVINGcan use aggregate functions,WHEREcannot
Indexes for Performance
Create indexes to speed up queries.
Single-Column Index
-- Create index on products.category for faster filtering
CREATE INDEX idx_products_category ON products(category);
-- Create index on orders.user_id for faster joins
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Create index on order_items.order_id for faster joins
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
-- Verify indexes
\diOutput:
List of relations
Schema | Name | Type | Owner | Table
--------+---------------------------+-------+----------+-------------
public | idx_orders_user_id | index | postgres | orders
public | idx_order_items_order_id | index | postgres | order_items
public | idx_products_category | index | postgres | products
(3 rows)Composite Index
-- Create composite index for common query pattern
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- This index speeds up queries filtering by both user_id and status
SELECT * FROM orders WHERE user_id = 1 AND status = 'delivered';Index benefits:
- Speeds up WHERE clauses
- Accelerates JOIN operations
- Improves ORDER BY performance
- Trade-off: Slower writes, more storage
Explain Query Plan
-- Analyze query execution plan
EXPLAIN ANALYZE
SELECT p.name, p.price
FROM products p
WHERE p.category = 'Electronics';Output:
Index Scan using idx_products_category on products p (cost=0.14..8.16 rows=1 width=40)
Index Cond: ((category)::text = 'Electronics'::text)
Planning Time: 0.123 ms
Execution Time: 0.045 ms
(4 rows)EXPLAIN keywords:
EXPLAIN: Show query plan (no execution)EXPLAIN ANALYZE: Execute query and show actual timingsSeq Scan: Full table scan (slow for large tables)Index Scan: Uses index (fast)
Transactions for Data Consistency
Group multiple operations into atomic units.
Basic Transaction
-- Start transaction
BEGIN;
-- Deduct product stock
UPDATE products
SET stock_quantity = stock_quantity - 1
WHERE id = 1;
-- Create order
INSERT INTO orders (user_id, status, total_amount)
VALUES (3, 'pending', 999.99);
-- Create order item
INSERT INTO order_items (order_id, product_id, quantity, price_at_purchase)
VALUES (currval('orders_id_seq'), 1, 1, 999.99);
-- Commit transaction (make changes permanent)
COMMIT;
-- Verify changes
SELECT stock_quantity FROM products WHERE id = 1;Output:
stock_quantity
----------------
49
(1 row)Transaction commands:
BEGIN: Start transactionCOMMIT: Save changes permanentlyROLLBACK: Discard all changes since BEGIN
Transaction with Rollback
-- Start transaction
BEGIN;
-- Try to create order with negative stock
UPDATE products
SET stock_quantity = stock_quantity - 100
WHERE id = 2;
-- Check constraint violation (stock would be negative)
SELECT stock_quantity FROM products WHERE id = 2;
-- Rollback transaction (undo changes)
ROLLBACK;
-- Verify rollback - stock unchanged
SELECT stock_quantity FROM products WHERE id = 2;Output:
stock_quantity
----------------
200
(1 row)ACID properties:
- Atomicity: All operations succeed or all fail
- Consistency: Database moves from valid state to valid state
- Isolation: Concurrent transactions don’t interfere
- Durability: Committed changes survive system failures
JSON Support with JSONB
Store flexible, semi-structured data using JSONB.
Add JSONB Column
-- Add metadata column to products
ALTER TABLE products ADD COLUMN metadata JSONB;
-- Update products with JSON metadata
UPDATE products
SET metadata = '{"brand": "TechCorp", "warranty_years": 2, "features": ["SSD", "16GB RAM", "Backlit Keyboard"]}'
WHERE id = 1;
UPDATE products
SET metadata = '{"brand": "TechCorp", "wireless": true, "dpi": 1600}'
WHERE id = 2;
UPDATE products
SET metadata = '{"brand": "ComfortSeating", "adjustable": true, "material": "mesh"}'
WHERE id = 3;
UPDATE products
SET metadata = '{"brand": "KitchenPro", "capacity_ml": 350, "dishwasher_safe": true}'
WHERE id = 4;
-- Verify JSON data
SELECT id, name, metadata FROM products;Output:
id | name | metadata
----+-------------+-------------------------------------------------------------------------------------------
1 | Laptop | {"brand": "TechCorp", "features": ["SSD", "16GB RAM", "Backlit Keyboard"], "warranty_years": 2}
2 | Mouse | {"dpi": 1600, "brand": "TechCorp", "wireless": true}
3 | Desk Chair | {"brand": "ComfortSeating", "material": "mesh", "adjustable": true}
4 | Coffee Mug | {"brand": "KitchenPro", "capacity_ml": 350, "dishwasher_safe": true}
(4 rows)Query JSON Data
-- Query products by JSON field
SELECT name, metadata->>'brand' AS brand
FROM products
WHERE metadata->>'brand' = 'TechCorp';Output:
name | brand
----------+----------
Laptop | TechCorp
Mouse | TechCorp
(2 rows)JSON Operators
-- Extract JSON field as text (->>')
SELECT name, metadata->>'brand' AS brand FROM products WHERE id = 1;
-- Output: Laptop | TechCorp
-- Extract JSON field as JSON (->)
SELECT name, metadata->'features' AS features FROM products WHERE id = 1;
-- Output: Laptop | ["SSD", "16GB RAM", "Backlit Keyboard"]
-- Check JSON key existence (?)
SELECT name FROM products WHERE metadata ? 'wireless';
-- Output: Mouse
-- Check JSON value (@>)
SELECT name FROM products WHERE metadata @> '{"wireless": true}';
-- Output: Mouse
-- Extract nested JSON path (#>>)
SELECT name, metadata#>>'{features,0}' AS first_feature FROM products WHERE id = 1;
-- Output: Laptop | SSDIndex JSON Fields
-- Create GIN index for JSON containment queries
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
-- Fast query using index
SELECT name FROM products WHERE metadata @> '{"brand": "TechCorp"}';JSONB benefits:
- Flexible schema (no migrations for new fields)
- Efficient storage (binary format)
- Fast indexing (GIN indexes)
- Rich operators for querying
Full-Text Search
Search text content efficiently.
Add Full-Text Search Column
-- Add tsvector column for full-text search
ALTER TABLE products ADD COLUMN search_vector tsvector;
-- Populate search vector from name and description
UPDATE products
SET search_vector = to_tsvector('english', name || ' ' || COALESCE(description, ''));
-- Create GIN index for fast full-text search
CREATE INDEX idx_products_search ON products USING GIN (search_vector);
-- Verify search vectors
SELECT id, name, search_vector FROM products;Output:
id | name | search_vector
----+-------------+----------------------------------------------------------
1 | Laptop | 'high':1 'high-perform':1 'laptop':2 'perform':1
2 | Mouse | 'mous':2 'optic':1 'wireless':1
3 | Desk Chair | 'chair':2 'desk':1 'ergonomic':1 'offic':1
4 | Coffee Mug | 'ceramic':1 'coffe':1,2 'mug':2
(4 rows)Search with to_tsquery
-- Search for products containing "laptop"
SELECT name, description
FROM products
WHERE search_vector @@ to_tsquery('english', 'laptop');Output:
name | description
--------+----------------------------
Laptop | High-performance laptop
(1 row)Ranked Search Results
-- Search with relevance ranking
SELECT
name,
description,
ts_rank(search_vector, query) AS rank
FROM products, to_tsquery('english', 'wireless | laptop') AS query
WHERE search_vector @@ query
ORDER BY rank DESC;Output:
name | description | rank
--------+----------------------------+-----------
Laptop | High-performance laptop | 0.607927
Mouse | Wireless optical mouse | 0.607927
(2 rows)Full-text search operators:
@@: Matches search queryto_tsvector(): Convert text to search vectorto_tsquery(): Convert text to search queryts_rank(): Relevance score (higher = more relevant)- Query operators:
&(AND),|(OR),!(NOT)
Common Table Expressions (CTEs)
Write readable queries with temporary named results.
Basic CTE
-- Calculate order totals using CTE
WITH order_totals AS (
SELECT
order_id,
SUM(quantity * price_at_purchase) AS calculated_total
FROM order_items
GROUP BY order_id
)
SELECT
o.id,
u.username,
o.total_amount AS stored_total,
ot.calculated_total,
(o.total_amount = ot.calculated_total) AS totals_match
FROM orders o
INNER JOIN users u ON o.user_id = u.id
INNER JOIN order_totals ot ON o.id = ot.order_id;Output:
id | username | stored_total | calculated_total | totals_match
----+----------+--------------+------------------+--------------
1 | alice | 1019.98 | 1019.98 | t
2 | bob | 299.99 | 299.99 | t
3 | alice | 9.99 | 9.99 | t
(3 rows)Multiple CTEs
-- Find top customers and their favorite categories
WITH customer_spending AS (
SELECT
u.id AS user_id,
u.username,
SUM(o.total_amount) AS total_spent
FROM users u
INNER JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username
),
customer_categories AS (
SELECT
u.id AS user_id,
p.category,
COUNT(*) AS purchase_count
FROM users u
INNER JOIN orders o ON u.id = o.user_id
INNER JOIN order_items oi ON o.id = oi.order_id
INNER JOIN products p ON oi.product_id = p.id
GROUP BY u.id, p.category
)
SELECT
cs.username,
cs.total_spent,
cc.category AS favorite_category,
cc.purchase_count
FROM customer_spending cs
INNER JOIN customer_categories cc ON cs.user_id = cc.user_id
ORDER BY cs.total_spent DESC, cc.purchase_count DESC;Output:
username | total_spent | favorite_category | purchase_count
----------+-------------+-------------------+----------------
alice | 1029.97 | Electronics | 2
alice | 1029.97 | Kitchen | 1
bob | 299.99 | Furniture | 1
(3 rows)CTE benefits:
- Improves query readability
- Reuses subquery results
- Supports recursive queries (not shown)
- Can be referenced multiple times
Next Steps
You’ve learned PostgreSQL’s core concepts covering 5-30% of the database. Continue learning:
- By-Example Tutorial - 85 annotated examples covering 95% of PostgreSQL (beginner, intermediate, advanced)
- Beginner Tutorial (coming soon) - Narrative-driven comprehensive guide
- Official PostgreSQL Documentation - Comprehensive reference
What you’ve learned:
- Table creation with constraints and data types
- Relationships with foreign keys (one-to-many, many-to-many)
- Joins (INNER, LEFT) for querying related data
- Aggregation with GROUP BY and HAVING
- Indexes for query performance
- Transactions for data consistency
- JSON support with JSONB for flexible schemas
- Full-text search for content discovery
- CTEs for readable queries
Topics to explore next:
- Window functions for analytics
- Partitioning for scalability
- Replication for high availability
- Advanced indexes (partial, expression, covering)
- Query optimization techniques
- Stored procedures and triggers
- Row-level security
Summary
This quick start covered essential PostgreSQL concepts through an e-commerce application:
Tables: Created tables with primary keys, foreign keys, and constraints for data integrity
Relationships: Linked tables with foreign keys for one-to-many and many-to-many relationships
Joins: Combined data from multiple tables with INNER and LEFT joins
Aggregation: Computed summaries with GROUP BY, COUNT, SUM, and HAVING
Indexes: Optimized queries with single-column, composite, and GIN indexes
Transactions: Ensured data consistency with BEGIN, COMMIT, and ROLLBACK
JSON: Stored flexible data with JSONB and queried with operators
Full-Text Search: Implemented content discovery with tsvector and GIN indexes
CTEs: Wrote readable queries with temporary named results
You’re now ready to build applications with relational data, powerful queries, and advanced PostgreSQL features. Continue to By-Example for deeper mastery covering 95% of PostgreSQL capabilities.