Master relational databases from setup to production. Learn SQL fundamentals, database design, indexing, transactions, and how to connect from Node.js. Covers both PostgreSQL and MySQL with CLI commands, real queries, and best practices.
Every real application needs to persist data. You could save JSON files, but that breaks down fast -- no concurrent access, no queries, no integrity guarantees. That's why relational databases exist.
Relational (PostgreSQL, MySQL) -- structured data with relationships, transactions matter, complex queries. Use for: user accounts, orders, financial data, most web apps.
Document (MongoDB) -- flexible schema, nested data, rapid prototyping. Use for: CMS, catalogs, event logging.
Key-Value (Redis) -- simple lookups, caching, sessions. Use for: cache layer, rate limiting, leaderboards.
Graph (Neo4j) -- deeply connected data. Use for: social networks, recommendation engines.
If you're unsure, pick PostgreSQL. It handles JSON (like MongoDB), has full-text search, geospatial queries, and is the most feature-rich open-source RDBMS. Most startups and companies use it.
| Feature | PostgreSQL | MySQL |
|---|---|---|
| Standards compliance | Excellent | Good |
| JSON support | JSONB (indexed, fast) | JSON (functional) |
| Full-text search | Built-in, powerful | Basic |
| Replication | Streaming, logical | Binary log, group |
| Extensions | PostGIS, pg_trgm, etc. | Plugins |
| Used by | Instagram, Discord, Reddit | Facebook, Twitter, Uber |
Bash
# Ubuntu / WSL
sudo apt update
sudo apt install postgresql postgresql-contrib
sudo systemctl start postgresql
sudo systemctl enable postgresql
# Mac (Homebrew)
brew install postgresql@16
brew services start postgresql@16
# Check version
psql --version
Bash
# Switch to postgres user and open psql
sudo -u postgres psql
# Create your own user + database
CREATE USER sean WITH PASSWORD 'yourpassword' CREATEDB;
CREATE DATABASE myapp OWNER sean;
\q
# Now connect as yourself
psql -U sean -d myapp
psql
\l -- list all databases
\c dbname -- connect to database
\dt -- list tables
\d tablename -- describe table (columns, types, constraints)
\di -- list indexes
\du -- list users/roles
\x -- toggle expanded output (vertical display)
\timing -- toggle query timing
\i file.sql -- execute SQL file
\e -- open editor for query
\q -- quit
Bash
# Ubuntu / WSL
sudo apt update
sudo apt install mysql-server
sudo systemctl start mysql
sudo mysql_secure_installation
# Mac
brew install mysql
brew services start mysql
Bash
# Connect as root
sudo mysql
# Create user + database
CREATE USER 'sean'@'localhost' IDENTIFIED BY 'yourpassword';
CREATE DATABASE myapp;
GRANT ALL PRIVILEGES ON myapp.* TO 'sean'@'localhost';
FLUSH PRIVILEGES;
EXIT;
# Connect as yourself
mysql -u sean -p myapp
MySQL
SHOW DATABASES; -- list databases
USE dbname; -- switch database
SHOW TABLES; -- list tables
DESCRIBE tablename; -- show table structure
SHOW INDEX FROM tablename;-- list indexes
SHOW PROCESSLIST; -- active connections
SOURCE file.sql; -- execute SQL file
EXIT; -- quit
Go through SQLBolt -- it's the best free interactive SQL tutorial. Do every lesson. It takes about 2 hours and you'll have SQL basics locked in.
SQL
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- auto-incrementing integer
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
is_active BOOLEAN DEFAULT true
);
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 DEFAULT NOW()
);
CREATE TABLE comments (
id SERIAL PRIMARY KEY,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
SERIAL (auto-increment integer) is simple and fast. UUID (gen_random_uuid()) is better for distributed systems -- no collision across servers. For most apps, SERIAL is fine. Use UUID if you'll shard later.
SQL
INSERT INTO users (email, username, password_hash)
VALUES ('sean@example.com', 'sean', '$2b$10$hash...');
-- Insert multiple rows
INSERT INTO users (email, username, password_hash) VALUES
('alice@example.com', 'alice', '$2b$10$hash1'),
('bob@example.com', 'bob', '$2b$10$hash2');
-- Insert and return the created row
INSERT INTO posts (user_id, title, body)
VALUES (1, 'My First Post', 'Hello world!')
RETURNING id, title, created_at;
SQL
-- Basic query
SELECT * FROM users;
-- Specific columns with filter
SELECT id, username, email
FROM users
WHERE is_active = true
ORDER BY created_at DESC
LIMIT 10;
-- Pattern matching
SELECT * FROM users WHERE email LIKE '%@gmail.com';
-- IN clause
SELECT * FROM users WHERE id IN (1, 2, 3);
-- NULL checks
SELECT * FROM posts WHERE body IS NOT NULL;
SQL
-- Update with WHERE (always use WHERE!)
UPDATE users
SET username = 'seandev', is_active = false
WHERE id = 1
RETURNING *;
-- Delete specific rows
DELETE FROM comments WHERE id = 5;
-- Delete with subquery
DELETE FROM posts
WHERE user_id IN (
SELECT id FROM users WHERE is_active = false
);
UPDATE users SET is_active = false; will update EVERY row. Always include WHERE. In production, wrap destructive operations in a transaction so you can ROLLBACK if something goes wrong.
Joins combine rows from two or more tables based on a related column. This is the core power of relational databases.
Returns only rows that have matching values in both tables.
SQL
-- Get all posts with their author's username
SELECT p.id, p.title, u.username, p.created_at
FROM posts p
INNER JOIN users u ON p.user_id = u.id
WHERE p.published = true
ORDER BY p.created_at DESC;
users posts ┌──┬───────┐ ┌──┬─────────┬─────────┐ │id│username│ │id│ title │ user_id │ ├──┼───────┤ ├──┼─────────┼─────────┤ │ 1│ sean │◄────────│ 1│ Hello │ 1 │ │ 2│ alice │◄────────│ 2│ World │ 2 │ │ 3│ bob │ (no) │ 3│ Bye │ 1 │ └──┴───────┘ └──┴─────────┴─────────┘ Result: posts 1, 2, 3 with their usernames (bob has no posts -- not in result)
Returns ALL rows from the left table, plus matching rows from the right. If no match, right side is NULL.
SQL
-- All users, even those with no posts
SELECT u.username, COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.username
ORDER BY post_count DESC;
-- Find users who have never posted
SELECT u.username
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
WHERE p.id IS NULL;
SQL
-- RIGHT JOIN: all posts, even if user was deleted (rare in practice)
SELECT p.title, u.username
FROM users u
RIGHT JOIN posts p ON u.id = p.user_id;
-- FULL OUTER JOIN: all rows from both tables
SELECT u.username, p.title
FROM users u
FULL OUTER JOIN posts p ON u.id = p.user_id;
SQL
-- Employees table with manager_id referencing same table
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
manager_id INTEGER REFERENCES employees(id)
);
-- Get employee name with their manager's name
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
SQL
-- Posts with author name and comment count
SELECT
p.id,
p.title,
u.username AS author,
COUNT(c.id) AS comment_count
FROM posts p
INNER JOIN users u ON p.user_id = u.id
LEFT JOIN comments c ON p.id = c.post_id
WHERE p.published = true
GROUP BY p.id, p.title, u.username
ORDER BY comment_count DESC;
SQL
-- Posts per user
SELECT user_id, COUNT(*) AS total_posts
FROM posts
GROUP BY user_id;
-- Average, min, max
SELECT
COUNT(*) AS total,
AVG(price) AS avg_price,
MIN(price) AS cheapest,
MAX(price) AS most_expensive,
SUM(price) AS revenue
FROM orders
WHERE status = 'completed';
-- HAVING filters groups (WHERE filters rows)
SELECT user_id, COUNT(*) AS post_count
FROM posts
GROUP BY user_id
HAVING COUNT(*) > 5
ORDER BY post_count DESC;
Window functions perform calculations across rows related to the current row -- without collapsing them like GROUP BY.
SQL
-- ROW_NUMBER: assign sequential numbers
SELECT
username,
email,
ROW_NUMBER() OVER (ORDER BY created_at) AS row_num
FROM users;
-- RANK: rank with gaps for ties
SELECT
username,
score,
RANK() OVER (ORDER BY score DESC) AS rank
FROM leaderboard;
-- DENSE_RANK: rank without gaps
SELECT
username,
score,
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank
FROM leaderboard;
-- LAG / LEAD: access previous/next row
SELECT
date,
revenue,
LAG(revenue) OVER (ORDER BY date) AS prev_day,
revenue - LAG(revenue) OVER (ORDER BY date) AS change,
LEAD(revenue) OVER (ORDER BY date) AS next_day
FROM daily_revenue;
-- Running total with SUM window
SELECT
date,
amount,
SUM(amount) OVER (ORDER BY date) AS running_total
FROM transactions;
-- Partition: window per group
SELECT
department,
employee,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
FROM employees;
Learn ROW_NUMBER, RANK, LAG, LEAD, and SUM OVER. These come up constantly in interviews and real analytics queries. They let you avoid self-joins and subqueries.
1NF: Every column has atomic (single) values. No arrays or comma-separated lists in a column.
2NF: 1NF + no partial dependencies. Every non-key column depends on the entire primary key (matters for composite keys).
3NF: 2NF + no transitive dependencies. Non-key columns don't depend on other non-key columns.
BCNF: Every determinant is a candidate key. Slightly stricter than 3NF.
SQL
-- DON'T: everything in one table
CREATE TABLE orders_bad (
order_id INTEGER,
customer_name VARCHAR(100),
customer_email VARCHAR(255), -- duplicated per order!
product_name VARCHAR(200),
product_price DECIMAL, -- duplicated per order!
quantity INTEGER
);
SQL
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
);
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price DECIMAL(10,2) NOT NULL
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
created_at TIMESTAMP DEFAULT NOW()
);
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),
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_at_time DECIMAL(10,2) NOT NULL -- snapshot price at order time
);
Always store the price at the time of purchase in the order_items table. If you just reference products.price, the order total changes when you update product prices. This is a real-world mistake people make.
One-to-Many (1:N): user has many posts. Put user_id foreign key on posts table.
Many-to-Many (M:N): users can have many tags, tags can have many users. Use a join/junction table (user_tags).
One-to-One (1:1): user has one profile. Put user_id UNIQUE foreign key on profiles table (or just add columns to users).
SQL
-- Many-to-Many: tags on posts
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
CREATE TABLE post_tags (
post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id) -- composite PK prevents duplicates
);
Without indexes, the database scans every row (sequential scan). Indexes are like a book's index -- they let the DB jump directly to matching rows.
SQL
-- B-tree index (default, most common)
CREATE INDEX idx_posts_user_id ON posts(user_id);
-- Unique index (also enforces uniqueness)
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- Composite index (order matters!)
CREATE INDEX idx_posts_user_published
ON posts(user_id, published);
-- Partial index (only index a subset of rows)
CREATE INDEX idx_active_users
ON users(email) WHERE is_active = true;
-- GIN index for full-text search / JSONB
CREATE INDEX idx_posts_body_search
ON posts USING GIN(to_tsvector('english', body));
See exactly how the database executes your query:
SQL
EXPLAIN ANALYZE
SELECT * FROM posts WHERE user_id = 1;
-- Output tells you:
-- Seq Scan = full table scan (slow)
-- Index Scan = used an index (fast)
-- Bitmap Index Scan = used index for many rows
-- actual time = real execution time
-- rows = actual rows returned
Indexes speed up reads but slow down writes (every INSERT/UPDATE must also update the index). Don't index everything -- index columns you frequently filter, join, or sort by. Drop unused indexes.
1. Foreign keys (user_id, post_id) -- always index these
2. Columns in WHERE clauses you query frequently
3. Columns in ORDER BY
4. Columns in JOIN conditions
5. Don't index boolean columns or columns with very few distinct values
Atomicity: All operations in a transaction succeed or all fail. No partial updates.
Consistency: Transaction moves the database from one valid state to another.
Isolation: Concurrent transactions don't interfere with each other.
Durability: Once committed, data survives crashes.
SQL
-- Transfer money between accounts
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Check constraint: balance >= 0
-- If any statement fails, both are rolled back
COMMIT;
-- or ROLLBACK; to cancel
READ UNCOMMITTED: Can see other transactions' uncommitted changes (dirty reads). Almost never used.
READ COMMITTED (PostgreSQL default): Only sees committed data. Each statement sees a new snapshot.
REPEATABLE READ (MySQL default): Sees a snapshot from the start of the transaction. Same query returns same results.
SERIALIZABLE: Full isolation. Transactions behave as if run one after another. Slowest but safest.
SQL
-- Set isolation level
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ... your queries ...
COMMIT;
-- Row-level locking (SELECT FOR UPDATE)
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- This row is now locked until COMMIT/ROLLBACK
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
COMMIT;
Deadlock: Transaction A locks row 1 and waits for row 2. Transaction B locks row 2 and waits for row 1. Both wait forever. The database detects this and kills one transaction. Fix: always lock rows in the same order (e.g., by ascending ID).
SQL
-- Create a view: a saved query that acts like a table
CREATE VIEW published_posts AS
SELECT p.id, p.title, u.username, p.created_at
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.published = true;
-- Use it like a table
SELECT * FROM published_posts WHERE username = 'sean';
-- Materialized view: cached result, must be refreshed
CREATE MATERIALIZED VIEW user_stats AS
SELECT u.id, u.username, COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id, u.username;
REFRESH MATERIALIZED VIEW user_stats;
SQL
-- CTE: temporary named result set
WITH active_authors AS (
SELECT user_id, COUNT(*) AS post_count
FROM posts
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY user_id
HAVING COUNT(*) >= 3
)
SELECT u.username, aa.post_count
FROM active_authors aa
JOIN users u ON aa.user_id = u.id
ORDER BY aa.post_count DESC;
-- Recursive CTE: traverse hierarchies
WITH RECURSIVE org_chart AS (
-- Base case: top-level managers
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive case: employees under each manager
SELECT e.id, e.name, e.manager_id, oc.depth + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT REPEAT(' ', depth) || name AS org_tree
FROM org_chart
ORDER BY depth, name;
PostgreSQL's JSONB type lets you store and query JSON data with indexes. It's like having MongoDB inside PostgreSQL.
SQL
CREATE TABLE events (
id SERIAL PRIMARY KEY,
type VARCHAR(50),
data JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO events (type, data) VALUES
('click', '{"page": "/home", "x": 120, "y": 450, "user_agent": "Chrome"}'),
('purchase', '{"product_id": 42, "amount": 29.99, "currency": "USD"}');
-- Access JSON fields
SELECT data->'page' AS page -- returns JSON: "/home"
FROM events WHERE type = 'click';
SELECT data->>'page' AS page -- returns text: /home
FROM events WHERE type = 'click';
-- Nested access
SELECT data->'address'->>'city' FROM customers;
-- Filter by JSON field
SELECT * FROM events
WHERE data->>'currency' = 'USD'
AND (data->>'amount')::numeric > 20;
-- Contains operator @>
SELECT * FROM events
WHERE data @> '{"currency": "USD"}';
-- Index JSONB for fast queries
CREATE INDEX idx_events_data ON events USING GIN(data);
SQL
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT,
tags TEXT[] DEFAULT '{}'
);
INSERT INTO articles (title, tags) VALUES
('Learn SQL', ARRAY['sql', 'database', 'tutorial']);
-- Check if array contains value
SELECT * FROM articles WHERE 'sql' = ANY(tags);
-- Array overlap
SELECT * FROM articles WHERE tags && ARRAY['sql', 'go'];
-- Unnest: expand array into rows
SELECT id, unnest(tags) AS tag FROM articles;
SQL
-- Basic full-text search
SELECT title, body
FROM posts
WHERE to_tsvector('english', title || ' ' || body)
@@ to_tsquery('english', 'database & design');
-- Add a generated tsvector column for performance
ALTER TABLE posts ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
) STORED;
CREATE INDEX idx_posts_search ON posts USING GIN(search_vector);
InnoDB (default since MySQL 5.5): Supports transactions, foreign keys, row-level locking. Use this for everything.
MyISAM: No transactions, table-level locking, faster full-table reads. Legacy -- avoid for new projects.
SQL
-- Check engine of a table
SHOW TABLE STATUS LIKE 'users';
-- Create with specific engine
CREATE TABLE logs (
id INT AUTO_INCREMENT PRIMARY KEY,
msg TEXT
) ENGINE=InnoDB;
-- MySQL JSON support
CREATE TABLE configs (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
settings JSON
);
INSERT INTO configs (name, settings) VALUES
('app', '{"theme": "dark", "lang": "en", "notifications": true}');
-- Access JSON (MySQL uses -> and ->>)
SELECT name, settings->>'$.theme' AS theme FROM configs;
-- MySQL AUTO_INCREMENT vs PostgreSQL SERIAL
-- MySQL: INT AUTO_INCREMENT
-- PostgreSQL: SERIAL (or GENERATED ALWAYS AS IDENTITY)
SQL
-- Check binary logging (needed for replication)
SHOW VARIABLES LIKE 'log_bin';
-- On source server: create replication user
CREATE USER 'repl'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
-- On replica: configure and start
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = '192.168.1.100',
SOURCE_USER = 'repl',
SOURCE_PASSWORD = 'password',
SOURCE_LOG_FILE = 'mysql-bin.000001',
SOURCE_LOG_POS = 0;
START REPLICA;
JavaScript
// npm install pg
import pg from 'pg';
// Connection pool (recommended for production)
const pool = new pg.Pool({
host: 'localhost',
port: 5432,
database: 'myapp',
user: 'sean',
password: process.env.DB_PASSWORD,
max: 20, // max connections in pool
idleTimeoutMillis: 30000,
});
// Simple query
const result = await pool.query('SELECT * FROM users WHERE id = $1', [1]);
console.log(result.rows[0]);
// Parameterized queries prevent SQL injection
const { rows } = await pool.query(
'SELECT * FROM users WHERE email = $1 AND is_active = $2',
[email, true]
);
// Insert and return
const { rows: [newPost] } = await pool.query(
'INSERT INTO posts (user_id, title, body) VALUES ($1, $2, $3) RETURNING *',
[userId, title, body]
);
// Transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, fromId]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, toId]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
JavaScript
// npm install mysql2
import mysql from 'mysql2/promise';
const pool = mysql.createPool({
host: 'localhost',
user: 'sean',
password: process.env.DB_PASSWORD,
database: 'myapp',
waitForConnections: true,
connectionLimit: 10,
});
// MySQL uses ? for parameters (not $1, $2)
const [rows] = await pool.execute(
'SELECT * FROM users WHERE email = ? AND is_active = ?',
[email, true]
);
// Insert
const [result] = await pool.execute(
'INSERT INTO posts (user_id, title) VALUES (?, ?)',
[userId, title]
);
console.log(result.insertId);
JavaScript
// Prisma -- schema-first ORM
// prisma/schema.prisma defines your models
// Then: npx prisma generate && npx prisma migrate dev
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Find users with posts
const users = await prisma.user.findMany({
where: { isActive: true },
include: { posts: true },
orderBy: { createdAt: 'desc' },
take: 10,
});
// Create with relations
const user = await prisma.user.create({
data: {
email: 'sean@dev.com',
username: 'sean',
posts: {
create: [{ title: 'First Post', body: 'Hello!' }],
},
},
include: { posts: true },
});
// Transaction in Prisma
await prisma.$transaction([
prisma.account.update({ where: { id: fromId }, data: { balance: { decrement: 100 } } }),
prisma.account.update({ where: { id: toId }, data: { balance: { increment: 100 } } }),
]);
JavaScript
// Drizzle -- SQL-like TypeScript ORM
// npm install drizzle-orm pg
import { drizzle } from 'drizzle-orm/node-postgres';
import { pgTable, serial, text, boolean, timestamp } from 'drizzle-orm/pg-core';
import { eq } from 'drizzle-orm';
const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
username: text('username').notNull(),
isActive: boolean('is_active').default(true),
});
const db = drizzle(pool);
// Select
const activeUsers = await db.select().from(users).where(eq(users.isActive, true));
// Insert
await db.insert(users).values({ email: 'sean@dev.com', username: 'sean' });
Prisma: Schema file, migrations, great DX, heavier. Best for rapid development.
Drizzle: SQL-like syntax, lighter, faster, TypeScript-native. Best when you want to stay close to SQL.
Both are excellent. Drizzle is trending for new projects in 2024+.
Never modify production schemas by hand. Use migration tools that track schema changes as versioned files:
Prisma: npx prisma migrate dev --name add_posts_table
Drizzle: npx drizzle-kit generate:pg && npx drizzle-kit push:pg
Raw SQL: Use golang-migrate or node-pg-migrate
Each migration is a file with UP (apply) and DOWN (rollback) SQL. Commit them to git.
Bash
# PostgreSQL backup
pg_dump -U sean myapp > backup_2024_01_15.sql
pg_dump -U sean -Fc myapp > backup.dump # compressed format
# Restore
psql -U sean myapp < backup_2024_01_15.sql
pg_restore -U sean -d myapp backup.dump
# MySQL backup
mysqldump -u sean -p myapp > backup.sql
mysql -u sean -p myapp < backup.sql
# Automate with cron (daily at 2am)
# 0 2 * * * pg_dump -U sean myapp | gzip > /backups/myapp_$(date +\%Y\%m\%d).sql.gz
1. Never use the postgres/root superuser in your app. Create a limited user.
2. Always use parameterized queries. Never concatenate user input into SQL strings.
3. Restrict network access. Don't expose port 5432/3306 to the internet.
4. Encrypt connections with SSL in production.
5. Hash passwords with bcrypt/argon2 before storing.
6. Backup daily and test your restore process.
7. Monitor slow queries with pg_stat_statements or MySQL slow query log.
1. Use EXPLAIN ANALYZE on slow queries
2. Add indexes on columns you filter/join by
3. Use connection pooling (PgBouncer or pool in your driver)
4. Avoid SELECT * -- only select columns you need
5. Use LIMIT for pagination, never load all rows
6. Use COPY instead of INSERT for bulk loading
7. Vacuum regularly (PostgreSQL auto-vacuums by default)
8. Consider materialized views for expensive aggregations