SQL for Beginners: Query Databases Like You've Done It for Years
Learn SQL from scratch with practical examples. SELECT, WHERE, JOINs, GROUP BY, CREATE TABLE, and more using a bookstore database you build yourself.
Every app you use stores data somewhere. Instagram saves your posts, Amazon tracks your orders, Spotify remembers your playlists. Behind almost all of it is a relational database, and the language you use to talk to that database is SQL.
SQL (Structured Query Language) has been around since the 1970s, and it's not going anywhere. It's used by backend developers, data analysts, data scientists, product managers, and anyone who needs to pull information from a database. Learning SQL isn't just useful -- it's one of the highest-leverage skills you can pick up.
The best part? SQL reads like English. Once you understand the patterns, writing queries feels natural.
What Is a Database?
A database is an organized collection of data. A relational database stores data in tables -- think spreadsheets with rows and columns. Each table represents one type of thing (books, customers, orders), and tables can reference each other through relationships.
Popular relational database systems include PostgreSQL, MySQL, SQLite, and Microsoft SQL Server. They all use SQL with minor syntax differences. Everything in this guide works across all of them.
Setting Up
The fastest way to start is SQLite, which requires zero setup. But for following along interactively, try any of these:
- DB Fiddle (db-fiddle.com) -- browser-based, no install
- SQLiteOnline (sqliteonline.com) -- instant SQLite in the browser
- PostgreSQL or MySQL installed locally
Creating Your First Table
Before querying data, you need a place to store it. The CREATE TABLE statement defines a table's structure:
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
genre TEXT,
price DECIMAL(5, 2),
pages INTEGER,
published_year INTEGER,
in_stock BOOLEAN DEFAULT TRUE
);
Let's break that down:
id INTEGER PRIMARY KEY-- a unique identifier for each row. A primary key means no two rows can have the same id.TEXT,INTEGER,DECIMAL,BOOLEAN-- data types that tell the database what kind of values to expect.NOT NULL-- this column must have a value. You can't insert a book without a title.DEFAULT TRUE-- if you don't specifyin_stock, it defaults to true.
| Type | What It Stores |
|---|---|
| INTEGER | Whole numbers |
| DECIMAL(p, s) | Exact decimal numbers (p total digits, s after decimal) |
| TEXT / VARCHAR(n) | Strings (VARCHAR limits length) |
| BOOLEAN | True or false |
| DATE | Calendar dates |
| TIMESTAMP | Date and time |
Inserting Data
Empty tables aren't useful. Let's add some books:
INSERT INTO books (id, title, author, genre, price, pages, published_year)
VALUES (1, 'The Pragmatic Programmer', 'David Thomas', 'Technology', 49.99, 352, 2019);
INSERT INTO books (id, title, author, genre, price, pages, published_year)
VALUES (2, 'Dune', 'Frank Herbert', 'Science Fiction', 9.99, 688, 1965);
INSERT INTO books (id, title, author, genre, price, pages, published_year)
VALUES (3, 'Clean Code', 'Robert C. Martin', 'Technology', 39.99, 464, 2008);
You can also insert multiple rows at once:
INSERT INTO books (id, title, author, genre, price, pages, published_year)
VALUES
(4, '1984', 'George Orwell', 'Fiction', 8.99, 328, 1949),
(5, 'Design Patterns', 'Gang of Four', 'Technology', 44.99, 416, 1994),
(6, 'The Hobbit', 'J.R.R. Tolkien', 'Fantasy', 10.99, 310, 1937),
(7, 'Neuromancer', 'William Gibson', 'Science Fiction', 11.99, 271, 1984),
(8, 'To Kill a Mockingbird', 'Harper Lee', 'Fiction', 7.99, 336, 1960),
(9, 'Introduction to Algorithms', 'Thomas Cormen', 'Technology', 89.99, 1312, 2009),
(10, 'The Great Gatsby', 'F. Scott Fitzgerald', 'Fiction', 6.99, 180, 1925);
SELECT: Getting Data Out
The SELECT statement is the most common SQL command. It retrieves data from a table.
Get everything:
SELECT * FROM books;
The * means "all columns." It works, but in real applications you should name the columns you need:
SELECT title, author, price FROM books;
This is better for two reasons: it's faster (the database doesn't have to fetch columns you don't need) and it makes your intent clear.
WHERE: Filtering Results
Most of the time you don't want every row. WHERE filters results:
SELECT title, price FROM books
WHERE genre = 'Technology';
This returns only technology books. You can use several operators:
-- Books under $15
SELECT title, price FROM books
WHERE price < 15.00;
-- Books NOT in the Fiction genre
SELECT title, genre FROM books
WHERE genre != 'Fiction';
-- Books between 300 and 500 pages
SELECT title, pages FROM books
WHERE pages BETWEEN 300 AND 500;
-- Books in specific genres
SELECT title, genre FROM books
WHERE genre IN ('Science Fiction', 'Fantasy');
-- Books with "The" in the title
SELECT title FROM books
WHERE title LIKE 'The%';
The LIKE operator does pattern matching. % matches any number of characters, _ matches exactly one.
You can combine conditions with AND and OR:
SELECT title, genre, price FROM books
WHERE genre = 'Technology' AND price < 50.00;
SELECT title, genre FROM books
WHERE genre = 'Fiction' OR genre = 'Fantasy';
ORDER BY and LIMIT
ORDER BY sorts results. LIMIT caps how many rows you get:
-- Cheapest books first
SELECT title, price FROM books
ORDER BY price ASC;
-- Most pages first
SELECT title, pages FROM books
ORDER BY pages DESC;
-- Top 3 most expensive books
SELECT title, price FROM books
ORDER BY price DESC
LIMIT 3;
ASC is ascending (default), DESC is descending.
Updating and Deleting Data
Made a mistake? Fix it with UPDATE:
UPDATE books
SET price = 42.99
WHERE id = 1;
Always include a WHERE clause with UPDATE. Without it, you'll update every row in the table. That's rarely what you want.
Removing rows works similarly:
DELETE FROM books
WHERE id = 10;
Same rule: always use WHERE with DELETE unless you genuinely want to empty the table.
Aggregate Functions: GROUP BY
SQL can summarize data with aggregate functions:
-- Total number of books
SELECT COUNT(*) AS total_books FROM books;
-- Average book price
SELECT AVG(price) AS average_price FROM books;
-- Most expensive book
SELECT MAX(price) AS highest_price FROM books;
-- Total value of all books
SELECT SUM(price) AS total_value FROM books;
-- Shortest book
SELECT MIN(pages) AS fewest_pages FROM books;
The AS keyword creates an alias -- a readable name for the result column.
GROUP BY lets you aggregate per category:
SELECT genre, COUNT(*) AS book_count, AVG(price) AS avg_price
FROM books
GROUP BY genre;
This gives you the number of books and average price for each genre. Every column in SELECT that isn't an aggregate function must appear in GROUP BY.
HAVING: Filtering Groups
WHERE filters individual rows. HAVING filters groups after aggregation:
SELECT genre, COUNT(*) AS book_count
FROM books
GROUP BY genre
HAVING COUNT(*) >= 2;
This returns only genres that have two or more books. You can't use WHERE here because COUNT(*) doesn't exist until after the grouping happens.
The order of clauses matters:
SELECT genre, AVG(price) AS avg_price
FROM books
WHERE published_year > 1980
GROUP BY genre
HAVING AVG(price) > 20.00
ORDER BY avg_price DESC;
Execution order: FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT.
JOINs: Connecting Tables
Real databases have multiple tables that reference each other. Let's create a customers and orders table:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
city TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
book_id INTEGER NOT NULL,
order_date DATE NOT NULL,
quantity INTEGER DEFAULT 1,
FOREIGN KEY (customer_id) REFERENCES customers(id),
FOREIGN KEY (book_id) REFERENCES books(id)
);
The FOREIGN KEY constraint says that customer_id must match an id in the customers table. This enforces data integrity -- you can't create an order for a customer that doesn't exist.
Add some data:
INSERT INTO customers (id, name, email, city)
VALUES
(1, 'Alice Chen', 'alice@example.com', 'Seattle'),
(2, 'Bob Martinez', 'bob@example.com', 'Austin'),
(3, 'Carol Johnson', 'carol@example.com', 'Denver');
INSERT INTO orders (id, customer_id, book_id, order_date, quantity)
VALUES
(1, 1, 1, '2026-03-01', 1),
(2, 1, 2, '2026-03-05', 2),
(3, 2, 3, '2026-03-10', 1),
(4, 3, 6, '2026-03-15', 1),
(5, 2, 9, '2026-03-20', 1);
INNER JOIN
An INNER JOIN returns rows only when there's a match in both tables:
SELECT customers.name, books.title, orders.quantity, orders.order_date
FROM orders
INNER JOIN customers ON orders.customer_id = customers.id
INNER JOIN books ON orders.book_id = books.id;
This gives you a readable list of who ordered what and when. Rows without a match in either table are excluded.
LEFT JOIN
A LEFT JOIN returns all rows from the left table, even if there's no match in the right table:
SELECT customers.name, orders.id AS order_id
FROM customers
LEFT JOIN orders ON customers.id = orders.customer_id;
If a customer has no orders, they still appear in the results -- with NULL in the order columns. This is useful for finding customers who haven't ordered anything:
SELECT customers.name
FROM customers
LEFT JOIN orders ON customers.id = orders.customer_id
WHERE orders.id IS NULL;
Putting It All Together
Here's a practical query that answers "What's each customer's total spending, and which customer spent the most?":
SELECT
c.name,
c.city,
COUNT(o.id) AS total_orders,
SUM(o.quantity * b.price) AS total_spent
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
INNER JOIN books b ON o.book_id = b.id
GROUP BY c.id, c.name, c.city
ORDER BY total_spent DESC;
Notice the aliases (c, o, b). They save typing and make complex queries readable.
Another useful one -- which genres generate the most revenue:
SELECT
b.genre,
COUNT(o.id) AS times_ordered,
SUM(o.quantity * b.price) AS revenue
FROM orders o
INNER JOIN books b ON o.book_id = b.id
GROUP BY b.genre
HAVING SUM(o.quantity * b.price) > 10.00
ORDER BY revenue DESC;
Common Mistakes Beginners Make
Forgetting WHERE on UPDATE/DELETE. This is the classic.UPDATE books SET price = 0 sets every book's price to zero. Always write the WHERE clause first.
Using SELECT * in production code. It works for exploration, but in real applications it's fragile. If someone adds a column to the table, your code might break or return data you didn't expect.
Confusing WHERE and HAVING. WHERE filters rows before grouping. HAVING filters groups after aggregation. If you're filtering on an aggregate function like COUNT() or SUM(), you need HAVING.
Not understanding NULL. NULL isn't zero or an empty string -- it means "unknown." You can't compare it with =. Use IS NULL or IS NOT NULL instead. WHERE price = NULL won't work. WHERE price IS NULL will.
Joining without understanding the relationship. Before writing a JOIN, ask yourself: which column in table A corresponds to which column in table B? Get this wrong and you'll get a cartesian product -- every row matched with every other row. Your 10-row query suddenly returns 100 rows.
What to Learn Next
You now have enough SQL to be useful. From here, the most valuable next steps are:
- Subqueries and CTEs (Common Table Expressions) -- queries inside queries, essential for complex data analysis
- Indexes -- how databases find rows quickly. Understanding indexes is the difference between a query that takes 5ms and one that takes 5 minutes
- Transactions -- grouping multiple operations so they either all succeed or all fail
- Views -- saved queries that act like virtual tables
- Window functions --
ROW_NUMBER(),RANK(),LAG(),LEAD()for advanced analytics
Explore more tutorials and guides at CodeUp.