Getting Started with PostgreSQL
Table of Contents
PostgreSQL (often called “Postgres”) is the most popular open-source relational database — and for good reason. It’s reliable, feature-rich, standards-compliant, and handles everything from small side projects to massive production workloads. If you’re choosing a database for a new project and don’t have a specific reason to pick something else, PostgreSQL is the safe default.
This tutorial gets you from installation to running queries in minutes.
Installing PostgreSQL
Connecting with psql
psql is PostgreSQL’s interactive terminal:
# Connect to default database
psql
# Connect to a specific database
psql -d myapp
# Connect with explicit options
psql -h localhost -p 5432 -U myuser -d myapp
Once connected, you’ll see a prompt like mydb=#. Useful commands:
| Command | Action |
|---|---|
\l |
List all databases |
\c dbname |
Connect to a database |
\dt |
List tables in current database |
\d tablename |
Describe a table’s structure |
\q |
Quit psql |
\? |
Help for psql commands |
Creating a Database and Tables
-- Create a database
CREATE DATABASE myapp;
-- Connect to it
\c myapp
-- Create tables
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
body TEXT,
published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create an index for common queries
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_published ON posts(published) WHERE published = TRUE;
Key PostgreSQL data types
| Type | Use Case |
|---|---|
SERIAL / BIGSERIAL |
Auto-incrementing IDs |
UUID |
Universally unique identifiers |
VARCHAR(n) |
Variable-length text with limit |
TEXT |
Unlimited text |
INTEGER / BIGINT |
Whole numbers |
DECIMAL(p, s) |
Exact numbers (money) |
BOOLEAN |
True/false |
TIMESTAMP WITH TIME ZONE |
Date + time (always use WITH TIME ZONE) |
JSONB |
Binary JSON (queryable) |
ARRAY |
Arrays of any type |
CRUD Operations
Insert
INSERT INTO users (username, email, password_hash)
VALUES ('alice', 'alice@example.com', '$2b$10$abc...');
INSERT INTO posts (user_id, title, body, published)
VALUES (1, 'My First Post', 'Hello, world!', TRUE);
-- Insert multiple rows
INSERT INTO posts (user_id, title, body, published) VALUES
(1, 'Second Post', 'More content', TRUE),
(1, 'Draft Post', 'Work in progress', FALSE);
Select
-- Basic query
SELECT * FROM users WHERE username = 'alice';
-- Specific columns with a join
SELECT u.username, p.title, p.created_at
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.published = TRUE
ORDER BY p.created_at DESC
LIMIT 10;
-- Aggregate
SELECT user_id, COUNT(*) AS post_count
FROM posts
WHERE published = TRUE
GROUP BY user_id
HAVING COUNT(*) > 5;
Update
UPDATE posts
SET title = 'Updated Title', updated_at = NOW()
WHERE id = 1;
-- Update with returning (get the updated row back)
UPDATE users SET email = 'newemail@example.com'
WHERE id = 1
RETURNING id, username, email;
Delete
DELETE FROM posts WHERE id = 3;
-- Delete with a condition
DELETE FROM posts
WHERE published = FALSE AND created_at < NOW() - INTERVAL '30 days';
PostgreSQL-Specific Features
JSONB columns
Store and query semi-structured data without sacrificing relational features:
CREATE TABLE events (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
INSERT INTO events (event_type, payload) VALUES
('user_signup', '{"user_id": 42, "source": "google", "plan": "free"}'),
('purchase', '{"user_id": 42, "amount": 29.99, "product": "pro_plan"}');
-- Query JSON fields
SELECT * FROM events
WHERE payload->>'user_id' = '42'
AND payload->>'source' = 'google';
-- Index JSON fields for performance
CREATE INDEX idx_events_user_id ON events ((payload->>'user_id'));
Arrays
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title VARCHAR(200),
tags TEXT[] DEFAULT '{}'
);
INSERT INTO articles (title, tags)
VALUES ('PostgreSQL Tips', ARRAY['database', 'postgresql', 'sql']);
-- Query arrays
SELECT * FROM articles WHERE 'postgresql' = ANY(tags);
RETURNING clause
Get data back from INSERT, UPDATE, or DELETE without a separate query:
INSERT INTO users (username, email, password_hash)
VALUES ('bob', 'bob@example.com', '$2b$10$xyz...')
RETURNING id, username, created_at;
Upsert (INSERT … ON CONFLICT)
INSERT INTO users (username, email, password_hash)
VALUES ('alice', 'alice@new-email.com', '$2b$10$new...')
ON CONFLICT (username)
DO UPDATE SET email = EXCLUDED.email;
Connecting from Application Code
Node.js (with pg)
import pg from "pg";
const pool = new pg.Pool({
host: "localhost",
port: 5432,
database: "myapp",
user: "myuser",
password: "mypassword"
});
// Parameterized query (prevents SQL injection)
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
["alice@example.com"]
);
console.log(result.rows[0]);
Python (with psycopg)
import psycopg
conn = psycopg.connect("dbname=myapp user=myuser password=mypassword")
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM users WHERE email = %s",
("alice@example.com",)
)
user = cur.fetchone()
print(user)
conn.close()
$1 or %s). Never concatenate user input into SQL strings — that’s how SQL injection attacks happen.
Essential Maintenance
-- Check table sizes
SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC;
-- Analyze query performance
EXPLAIN ANALYZE SELECT * FROM posts WHERE user_id = 1;
-- Vacuum (reclaim space from deleted rows)
VACUUM ANALYZE posts;
What’s Next
With PostgreSQL running, explore SQL JOINs for combining data across tables, or learn about normalization to design clean schemas. For production deployments, look into connection pooling, backups, and replication.