📋 SQL Basics
In the last lesson you chose a database. Now you learn to speak to it. SQL — Structured Query Language — is the language of relational databases, and it has stayed remarkably stable since the 1970s. Learn its handful of core verbs and you can command PostgreSQL, MySQL, SQLite, and every other relational system you'll ever meet.
Week 8 · Day 1 (Monday: Introduction to Databases) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what SQL is and how DDL differs from DML
- Create a table with columns, data types, and constraints
- Perform all four CRUD operations with SELECT, INSERT, UPDATE, and DELETE
- Filter rows precisely using the WHERE clause and its operators
- Summarize data with aggregate functions and GROUP BY
- Combine related tables with INNER JOIN and LEFT JOIN
Estimated Time: 70 minutes
Practice: Build a small users/orders schema, then write queries to insert, filter, aggregate, and join your data.
In This Lesson
What Is SQL?
SQL (often pronounced "sequel") is the standard language for interacting with relational databases. You write a declarative statement describing what data you want, and the database figures out how to get it. That's a different mindset from the JavaScript you've written so far: instead of looping over records yourself, you describe the result and let the database do the work.
The mental model is a conversation with a very literal librarian. You say, "Bring me every mystery novel published after 2020, sorted by author." SQL is the precise phrasing of that request:
SELECT title, author
FROM books
WHERE genre = 'Mystery' AND published_year > 2020
ORDER BY author;
SQL matters far beyond this course. It's the lingua franca of data analysis, powers business-intelligence tools, and turns up in nearly every backend job description. As a full-stack developer, fluent SQL is what lets you build features that are fast instead of accidentally slow.
DDL vs DML: SQL's Two Halves
SQL statements fall into a few groups. Two of them cover almost everything you'll do day to day:
- DDL — Data Definition Language defines structure:
CREATE,ALTER,DROP. Think "designing the shape of the filing cabinet." - DML — Data Manipulation Language works with the data inside:
SELECT,INSERT,UPDATE,DELETE. Think "putting papers in and taking them out."
You'll also meet DCL (GRANT/REVOKE for permissions) and TCL (COMMIT/ROLLBACK for transactions) later, but DDL and DML are the workhorses.
💡 The four DML verbs are "CRUD"
Every data-driven app boils down to four operations, and SQL has one verb for each: Create → INSERT, Read → SELECT, Update → UPDATE, Delete → DELETE. Master these four and you can build the data layer of almost anything.
Creating Tables
A table is where data lives — a grid of rows and columns, like a spreadsheet with strict rules. Before storing data you declare the table's structure: each column's name, its data type, and any constraints that keep the data valid.
The shape of a CREATE TABLE
CREATE TABLE table_name (
column_name data_type constraints,
column_name data_type constraints
-- ...
);
Common data types
| Category | Types | Use for |
|---|---|---|
| Numbers | INTEGER, BIGINT, DECIMAL(p,s) | Counts, ids, money (use DECIMAL, never floats, for currency) |
| Text | VARCHAR(n), TEXT | Names and short strings (VARCHAR); long free text (TEXT) |
| Dates/time | DATE, TIMESTAMP | Birthdays, created-at timestamps |
| Boolean | BOOLEAN | true/false flags like is_active |
| Postgres extras | SERIAL, JSONB, UUID | Auto-increment ids, embedded JSON, universally unique ids |
Constraints keep data honest
- PRIMARY KEY — uniquely identifies each row (implies
UNIQUE+NOT NULL) - FOREIGN KEY — the value must exist in another table's key, enforcing valid relationships
- UNIQUE — no two rows may share this value
- NOT NULL — the column must always have a value
- CHECK — the value must satisfy a condition
- DEFAULT — a fallback value when none is supplied
A real table
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- auto-incrementing id
username VARCHAR(50) UNIQUE NOT NULL, -- required, no duplicates
email VARCHAR(100) UNIQUE NOT NULL,
date_of_birth DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
CHECK (date_of_birth > '1900-01-01') -- sanity guard
);
📖 Designing a table is like designing a form
You decide what fields to collect (columns), what kind of answer each accepts (types), and which are required (constraints). The PRIMARY KEY is the customer number that uniquely tags each submission.
INSERT: Adding Data
INSERT adds new rows. You list the columns you're filling and the matching values.
-- One row
INSERT INTO users (username, email, date_of_birth)
VALUES ('ada', 'ada@example.com', '1990-03-20');
-- Several rows at once
INSERT INTO users (username, email, date_of_birth)
VALUES
('grace', 'grace@example.com', '1988-11-07'),
('linus', 'linus@example.com', '1985-07-15');
Notice we never set id, created_at, or is_active — the SERIAL and DEFAULT definitions fill those automatically. Let the database do that work; it's more reliable than doing it by hand.
Result
INSERT 0 1
INSERT 0 2 -- Postgres reports how many rows it added
SELECT & WHERE: Reading Data
SELECT is the statement you'll write most. It reads rows from a table; the WHERE clause narrows down which rows.
-- Every column, every row (fine for exploring, avoid in real code)
SELECT * FROM users;
-- Only the columns you need — faster and clearer
SELECT username, email FROM users;
-- Filter with WHERE
SELECT * FROM users WHERE is_active = TRUE;
-- Combine conditions, then sort and limit
SELECT username, created_at
FROM users
WHERE is_active = TRUE AND date_of_birth > '1988-01-01'
ORDER BY created_at DESC
LIMIT 10;
WHERE operators
| Operator | Meaning | Example |
|---|---|---|
= <> | equal / not equal | status = 'active' |
> < >= <= | comparisons | price <= 100 |
BETWEEN | within a range (inclusive) | age BETWEEN 18 AND 65 |
LIKE | pattern match (% = any characters) | name LIKE 'Sm%' |
IN | matches any value in a list | status IN ('shipped','delivered') |
IS NULL | checks for missing values | deleted_at IS NULL |
⚠️ NULL is not equal to anything — not even NULL
To find rows with no value, you must write WHERE last_name IS NULL. Writing WHERE last_name = NULL silently matches nothing, because NULL means "unknown," and unknown never equals unknown.
UPDATE & DELETE
UPDATE changes existing rows; DELETE removes them. Both take a WHERE clause that decides which rows are affected — and forgetting it is one of the most dangerous mistakes in all of SQL.
UPDATE
-- Deactivate one user
UPDATE users
SET is_active = FALSE
WHERE id = 5;
-- Change several columns at once
UPDATE users
SET email = 'new@example.com', username = 'ada_l'
WHERE id = 1;
-- You can compute new values from old ones
UPDATE products
SET price = price * 1.10 -- a 10% price rise
WHERE category = 'Electronics';
DELETE
-- Remove one specific row
DELETE FROM users WHERE id = 7;
-- Remove rows matching a condition
DELETE FROM users
WHERE is_active = FALSE AND created_at < '2022-01-01';
⚠️ The most expensive mistake in SQL
UPDATE users SET is_active = FALSE; -- 😱 EVERY user, no WHERE
DELETE FROM users; -- 😱 deletes the entire table's rows
Without a WHERE clause, UPDATE and DELETE hit every row. Before running either, ask: "Which rows do I mean?" Wrap risky changes in a transaction so you can ROLLBACK, and test the same condition with a SELECT first.
Aggregates & GROUP BY
Aggregate functions collapse many rows into a single summary value. They answer "how many," "what's the total," "what's the average."
| Function | Returns |
|---|---|
COUNT(*) | number of rows |
SUM(col) | total of a numeric column |
AVG(col) | average value |
MIN(col) / MAX(col) | smallest / largest value |
SELECT COUNT(*) FROM users WHERE is_active = TRUE; -- one number
SELECT AVG(price) FROM products WHERE category = 'Electronics';
GROUP BY: one summary per group
GROUP BY splits rows into buckets and runs the aggregate on each. "Average price per category" instead of one grand average:
SELECT category,
COUNT(*) AS product_count,
AVG(price) AS avg_price,
MAX(price) AS most_expensive
FROM products
GROUP BY category
ORDER BY avg_price DESC;
Result
category | product_count | avg_price | most_expensive
------------+---------------+-----------+---------------
Laptops | 12 | 899.00 | 1999.00
Phones | 20 | 649.50 | 1299.00
Cables | 45 | 14.20 | 39.00
HAVING: filter the groups
WHERE filters individual rows before grouping; HAVING filters the groups after aggregation.
-- Only categories whose average price tops $100
SELECT category, AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING AVG(price) > 100
ORDER BY avg_price DESC;
Joining Tables
The real power of relational databases is combining related tables. Remember our users and their orders? A JOIN stitches them back together on a shared key.
INNER JOIN — only matching rows
Returns rows that have a match in both tables. Orders that belong to a real user, users who have at least one order.
SELECT users.username, orders.id AS order_id, orders.order_date
FROM users
INNER JOIN orders ON users.id = orders.user_id
ORDER BY orders.order_date DESC;
LEFT JOIN — keep everything on the left
Returns all rows from the first (left) table, filling in NULL where the right table has no match. Perfect for "users and their orders, including users who've never ordered."
SELECT users.username, orders.id AS order_id
FROM users
LEFT JOIN orders ON users.id = orders.user_id;
-- A common trick: find users with NO orders
SELECT users.username
FROM users
LEFT JOIN orders ON users.id = orders.user_id
WHERE orders.id IS NULL; -- no matching order = NULL
💡 Table aliases save typing
You'll often see short aliases: FROM users u JOIN orders o ON u.id = o.user_id. Then you write u.username and o.order_date. It's the same query, just terser.
Practice & Quiz
🏋️ Exercise 1: Build and query
Goal: Create a products table, insert three rows, then write a query that returns only products under $50, cheapest first.
-- Your CREATE TABLE, INSERTs, and SELECT here
💡 Hint
Give products an id SERIAL PRIMARY KEY, a name VARCHAR, and a price DECIMAL(10,2). Filter with WHERE price < 50 and sort with ORDER BY price ASC.
✅ Solution
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL
);
INSERT INTO products (name, price) VALUES
('USB Cable', 9.99),
('Keyboard', 49.00),
('Monitor', 199.00);
SELECT name, price
FROM products
WHERE price < 50
ORDER BY price ASC;
-- Returns USB Cable (9.99) then Keyboard (49.00)
🏋️ Exercise 2: Aggregate and join
Goal: Given users(id, username) and orders(id, user_id, total), write one query that lists each username alongside how many orders they placed and their total spend — including users with zero orders.
💡 Hint
"Including users with zero orders" is the giveaway: use a LEFT JOIN. Group by the user, and use COUNT(orders.id) (not COUNT(*)) so zero-order users count as 0.
✅ Solution
SELECT u.username,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username
ORDER BY total_spent DESC;
-- COALESCE turns the NULL sum of zero-order users into 0
🎯 Quick Quiz
Question 1: Which statement adds a brand-new row to a table?
Question 2: What happens if you run DELETE FROM users; with no WHERE clause?
Question 3: You want all users, even those with no orders, paired with their orders. Which JOIN?
Best Practices & Pitfalls
✅ Do
- Always run a
SELECTwith yourWHEREcondition before anUPDATEorDELETE - Name the columns you want instead of
SELECT *— clearer and faster - Use parameterized queries in your app code (never string-concatenate user input)
- Wrap multi-step changes in a transaction so you can roll back on error
- Write SQL keywords in uppercase for readability (a widely followed convention)
❌ Don't
- Run
UPDATE/DELETEwithout aWHEREunless you truly mean "all rows" - Compare to NULL with
=— useIS NULL/IS NOT NULL - Loop in your app issuing one query per record when a single JOIN would do (the "N+1" problem)
- Store money in floating-point columns — use
DECIMAL
⚠️ SQL injection: the reason for parameterized queries
Never build queries by gluing user input into a string. An attacker can smuggle in extra SQL. Instead of `SELECT * FROM users WHERE name = '${input}'`, use placeholders your database driver fills safely — you'll do exactly this when we connect Node.js to PostgreSQL.
Summary
🎉 Key Takeaways
- SQL is the declarative language of relational databases — describe the result, not the steps
- DDL (
CREATE/ALTER/DROP) defines structure; DML is the CRUD verbs - SELECT + WHERE read and filter;
ORDER BYandLIMITshape the output - UPDATE and DELETE always need a
WHERE— or they hit every row - Aggregates + GROUP BY summarize data; JOINs combine related tables
📚 Additional Resources
🚀 What's Next?
You can now create tables and run CRUD queries — but how you structure those tables makes the difference between a database that scales and one that fights you. Next: Database Design Principles — entity-relationship modeling and normalization (1NF, 2NF, 3NF).
🎉 You speak SQL now!
These few verbs power the data layer of practically every backend on the web. Everything from here builds on them.