Home Blog SQL Tips

Complete SQL Tutorial for Beginners

SQL Tips Mohammad July 5, 2026

This is a complete, hands-on SQL tutorial for absolute beginners. You will go from "what even is a database?" to writing real joins, aggregations, and subqueries, using one small sample schema you can create and query yourself. Every concept comes with a runnable example and the result it produces.

SQL (Structured Query Language) is the language you use to talk to a relational database. If you have ever used a spreadsheet, you already understand the shape of the data: rows and columns organised into tables. SQL is just a precise, powerful way to ask questions of those tables and to change what they hold. It powers analytics dashboards, web apps, mobile backends, and reporting tools everywhere, and the core skills are the same whether you use MySQL, PostgreSQL, SQL Server, SQLite, or a cloud warehouse.

Work through the sections in order the first time. Type the examples out yourself rather than copying them - muscle memory matters. If you want the shortest possible primer before this, skim the introduction to SQL lesson, then come back here.

Where do you actually type SQL?

You run SQL inside a database client connected to a running database. Common options for a beginner are a browser sandbox (nothing to install), a desktop tool like DBeaver, MySQL Workbench, or pgAdmin, or the command-line client that ships with your database. Whatever you use, the workflow is the same: type a statement, run it, read the result grid, adjust, repeat. If you want zero setup, SQLite runs from a single file and every query in this guide works in it unchanged. Pick one environment now and keep it open in a second window as you read - a tutorial you run is worth ten you only read.

1. Databases, tables, and the sample schema

A database is a container for related data. Inside it you have tables, and each table stores one kind of thing - customers in one table, orders in another, products in a third. A table has named columns (each with a data type, like text or a number) and any number of rows (each row is one record). A relational database also lets tables reference each other, so an order can point back to the customer who placed it.

We will use three tables for the whole tutorial: customers, orders, and products. Here is the SQL that creates two of them. The exact keywords (like SERIAL vs AUTO_INCREMENT) vary slightly between databases; the shape is universal, and the DDL commands reference covers the dialect differences.

SQL-- One row per customer CREATE TABLE customers ( customer_id INT PRIMARY KEY, name VARCHAR(100) NOT NULL, city VARCHAR(80), signup_date DATE ); -- One row per order; each order belongs to a customer CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT NOT NULL, order_date DATE NOT NULL, amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) DEFAULT 'pending', FOREIGN KEY (customer_id) REFERENCES customers(customer_id) );

Now imagine we have inserted some sample data. Here is what a few rows of each table look like. We will query exactly these rows throughout the guide, so it is worth glancing at them now.

customer_idnamecitysignup_date
1Ava ChenLondon2024-01-12
2Ben OseiManchester2024-02-03
3Carla RuizLondon2024-03-21
4Dan ParkLeeds2024-05-09
order_idcustomer_idorder_dateamountstatus
10112024-04-02120.00shipped
10212024-04-1860.00shipped
10322024-04-20250.00pending
10432024-05-0145.50cancelled
10532024-06-11310.00shipped

Notice that customer 4 (Dan Park) has no orders, and every order's customer_id matches a real customer. That relationship is what makes joins possible later.

SQL is not case-sensitive for keywords. select and SELECT both work. Convention is to write keywords in UPPERCASE and your own names in lowercase so queries are easy to read. End each statement with a semicolon.

2. SELECT basics: columns, DISTINCT, aliases

Reading data is by far the most common thing you will do, and it always starts with SELECT. The two required parts are which columns you want and which table they come from. This family of read-only statements is called DQL - see the DQL commands guide for the full set.

SQL-- Every column, every row SELECT * FROM customers; -- Just the columns you need (preferred) SELECT name, city FROM customers;

Selecting only the columns you need is a good habit - it is clearer and faster than SELECT *. The result of the second query is:

namecity
Ava ChenLondon
Ben OseiManchester
Carla RuizLondon
Dan ParkLeeds

DISTINCT removes duplicates

Two customers live in London. If you only want the list of cities, not one entry per customer, use DISTINCT.

SQLSELECT DISTINCT city FROM customers; -- Returns: London, Manchester, Leeds (three rows, not four)

Aliases rename columns and tables

Use AS to give a column a friendlier name in the output, or to shorten a table name so you type less. You can also compute new columns with expressions.

SQLSELECT name AS customer_name, city AS location FROM customers; -- A computed column with an alias SELECT order_id, amount, amount * 0.2 AS tax FROM orders;

The AS keyword is optional in most databases (name customer_name works too), but writing it out keeps queries readable.

The order the database reads your query

You write a query as SELECT ... FROM ... WHERE ... ORDER BY ..., but the database does not run it in that order. It first finds the table (FROM), filters rows (WHERE), groups and aggregates them (GROUP BY / HAVING), then picks columns (SELECT), and finally sorts and limits (ORDER BY / LIMIT). Knowing this explains two things beginners find surprising: why you cannot use a SELECT alias inside a WHERE (the alias does not exist yet when WHERE runs), and why HAVING can filter on an aggregate but WHERE cannot.

3. Filtering rows with WHERE

The WHERE clause keeps only the rows that match a condition. It is where most of the real thinking in SQL happens. You can combine conditions with AND, OR, and NOT, and use a handful of special operators for ranges, lists, patterns, and missing values.

SQL-- Orders over 100 that shipped SELECT order_id, amount, status FROM orders WHERE amount > 100 AND status = 'shipped';

Against our sample data that returns orders 101 and 105:

order_idamountstatus
101120.00shipped
105310.00shipped

Here are the operators you will reach for most often, with what each one does:

OperatorMeaningExample
= <>Equal / not equalstatus = 'shipped'
> < >= <=Greater / less thanamount >= 100
AND OR NOTCombine or negate conditionscity = 'London' OR city = 'Leeds'
IN (...)Matches any value in a listcity IN ('London','Leeds')
BETWEEN a AND bInclusive rangeamount BETWEEN 50 AND 200
LIKEPattern match (% = any run, _ = one char)name LIKE 'A%'
IS NULL / IS NOT NULLTest for missing valuescity IS NULL
SQL-- Customers in London or Leeds, name starting with a letter A-C SELECT name, city FROM customers WHERE city IN ('London', 'Leeds') AND name LIKE 'A%'; -- Orders in a value range SELECT order_id FROM orders WHERE amount BETWEEN 50 AND 200;

Watch out: NULL means "unknown", so WHERE city = NULL never matches anything - not even rows where city really is empty. Always use IS NULL or IS NOT NULL to test for missing values.

4. Sorting and limiting results

ORDER BY sorts the output; without it, the database is free to return rows in any order. Add DESC for descending (high to low) or ASC for ascending (the default). You can sort by more than one column.

SQL-- Biggest orders first SELECT order_id, amount FROM orders ORDER BY amount DESC; -- Sort by city, then by newest signup within each city SELECT name, city, signup_date FROM customers ORDER BY city ASC, signup_date DESC;

To return only the top few rows, you cap the result set. This is one of the few places where the syntax genuinely differs by database:

DatabaseSyntax to get 3 rows
MySQL / PostgreSQL / SQLite... ORDER BY amount DESC LIMIT 3
SQL ServerSELECT TOP 3 ... ORDER BY amount DESC
ANSI standard (modern DBs)... ORDER BY amount DESC FETCH FIRST 3 ROWS ONLY
SQL-- The three largest orders (MySQL / Postgres / SQLite) SELECT order_id, amount FROM orders ORDER BY amount DESC LIMIT 3; -- Returns orders 105 (310.00), 103 (250.00), 101 (120.00)

Always pair a limit with an ORDER BY. "Give me 3 rows" without a sort means "give me any 3 rows", which is rarely what you want.

5. Inserting, updating, and deleting data

So far we have only read data. The commands that change data are INSERT, UPDATE, and DELETE - collectively DML. The DML commands guide goes deeper; here is the everyday form of each.

SQL-- Add a new customer INSERT INTO customers (customer_id, name, city, signup_date) VALUES (5, 'Ella Novak', 'Bristol', '2024-07-01'); -- Add several rows at once INSERT INTO orders (order_id, customer_id, order_date, amount, status) VALUES (106, 5, '2024-07-02', 89.99, 'pending'), (107, 2, '2024-07-03', 15.00, 'shipped');

UPDATE changes existing rows, and DELETE removes them. Both take a WHERE clause that decides which rows are affected.

SQL-- Mark one order as shipped UPDATE orders SET status = 'shipped' WHERE order_id = 103; -- Remove cancelled orders DELETE FROM orders WHERE status = 'cancelled';

Never run UPDATE or DELETE without a WHERE clause by accident. DELETE FROM orders; empties the entire table, and UPDATE orders SET status = 'shipped'; marks every order shipped. Write the WHERE first, run it as a SELECT to see which rows you will hit, and only then change SELECT to UPDATE or DELETE.

6. Creating tables: data types and constraints

You met CREATE TABLE in section 1. Two decisions define each column: its data type (what kind of value it holds) and its constraints (rules the value must obey). Choosing these well is the foundation of a healthy database. These are DDL statements; the full set, including ALTER TABLE, lives in the DDL commands reference.

Common data types

TypeHoldsExample value
INT / BIGINTWhole numbers42
DECIMAL(p,s)Exact decimals (money)120.00
VARCHAR(n)Text up to n characters'London'
TEXTLong, variable texta paragraph
DATEA calendar date2024-07-01
TIMESTAMPDate and time2024-07-01 14:30:00
BOOLEANTrue / falseTRUE

Use DECIMAL for money, never FLOAT - floating point cannot represent values like 0.10 exactly, and rounding errors creep into totals.

Constraints keep data valid

  • PRIMARY KEY - uniquely identifies each row (like customer_id). Cannot be null or duplicated.
  • FOREIGN KEY - forces a value to exist in another table, so an order cannot reference a customer who does not exist.
  • NOT NULL - the column must always have a value.
  • UNIQUE - no two rows may share the value (an email column, say).
  • CHECK - the value must satisfy a condition.
  • DEFAULT - a value used when none is supplied on insert.
SQLCREATE TABLE products ( product_id INT PRIMARY KEY, sku VARCHAR(40) NOT NULL UNIQUE, name VARCHAR(120) NOT NULL, price DECIMAL(10,2) NOT NULL CHECK (price >= 0), in_stock BOOLEAN DEFAULT TRUE );

These rules are enforced by the database itself, so bad data is rejected no matter which application tries to insert it. That is a huge reliability win over trusting every app to "remember the rules".

Changing a table after it exists

You rarely get the design perfect on the first try. ALTER TABLE lets you add, change, or drop columns and constraints without recreating the table or losing its data.

SQL-- Add a column ALTER TABLE customers ADD email VARCHAR(120); -- Remove a column ALTER TABLE customers DROP COLUMN email;

Two related commands are worth knowing: DROP TABLE deletes a table and all its rows permanently, and TRUNCATE TABLE empties a table of rows but keeps its structure. Both are fast and irreversible, so treat them with the same care as a DELETE without a WHERE.

7. Combining tables with JOINs

Real questions span more than one table: "which customer placed each order?" needs both orders and customers. A JOIN stitches rows together by matching a column they share - here, customer_id. This is the single most important skill in SQL, so take your time. For a visual, worked walkthrough, read the deeper SQL joins guide and the SQL joins lesson.

INNER JOIN: only matching rows

An INNER JOIN returns rows only where the match exists on both sides. Because Dan Park (customer 4) has no orders, he does not appear.

SQLSELECT o.order_id, c.name, o.amount FROM orders o INNER JOIN customers c ON c.customer_id = o.customer_id ORDER BY o.order_id;
order_idnameamount
101Ava Chen120.00
102Ava Chen60.00
103Ben Osei250.00
104Carla Ruiz45.50
105Carla Ruiz310.00

The o and c after each table name are aliases; they let you write o.amount instead of the full table name and make it clear which table each column comes from. The ON clause is the matching rule - it tells the database how rows from the two tables line up. Get that wrong and you either lose rows or multiply them, so always ask yourself "which column connects these two tables?" before writing the join.

You are not limited to two tables. Chain joins to pull in as many as you need - here we add product information to each order line, assuming an order_items table linked to both:

SQLSELECT c.name, o.order_id, o.amount FROM orders o JOIN customers c ON c.customer_id = o.customer_id WHERE o.amount > 100 ORDER BY o.amount DESC;

JOIN on its own means INNER JOIN - the word INNER is optional. The other types you will meet later are RIGHT JOIN (the mirror of LEFT) and FULL JOIN (keep unmatched rows from both sides), but INNER and LEFT cover the large majority of real work.

LEFT JOIN: keep every row from the left table

A LEFT JOIN keeps every row from the first (left) table even when there is no match on the right, filling the missing columns with NULL. This is exactly how you find customers with no orders.

SQLSELECT c.name, o.order_id FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE o.order_id IS NULL; -- Returns Dan Park, because he has no matching order
nameorder_id
Dan ParkNULL

Rule of thumb: use INNER JOIN when you only want rows that exist on both sides, and LEFT JOIN when you want to keep everything from the main table and see where matches are missing.

8. Aggregating with GROUP BY and HAVING

Aggregate functions collapse many rows into a single summary value. The five you will use constantly are COUNT, SUM, AVG, MIN, and MAX. On their own they summarise the whole table:

SQLSELECT COUNT(*) AS num_orders, SUM(amount) AS total_revenue, AVG(amount) AS avg_order, MIN(amount) AS smallest, MAX(amount) AS largest FROM orders;
num_orderstotal_revenueavg_ordersmallestlargest
5785.50157.1045.50310.00

GROUP BY: one summary per group

Add GROUP BY to get a summary per group instead of one for the whole table. To count orders and total spend per customer:

SQLSELECT customer_id, COUNT(*) AS orders, SUM(amount) AS total FROM orders GROUP BY customer_id ORDER BY total DESC;
customer_idorderstotal
32355.50
21250.00
12180.00

Every column in the SELECT must either be inside an aggregate function or listed in the GROUP BY. Mixing a plain column that is not grouped is one of the most common beginner errors.

One COUNT subtlety worth learning early: COUNT(*) counts rows, while COUNT(column) counts only rows where that column is not NULL. So COUNT(*) and COUNT(status) can return different numbers if some orders have no status. When you just want "how many rows", use COUNT(*). Add DISTINCT inside it - COUNT(DISTINCT customer_id) - to count unique values rather than every occurrence.

HAVING: filter groups after aggregating

WHERE filters individual rows before grouping; HAVING filters the groups after. To show only customers who have spent more than 200:

SQLSELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id HAVING SUM(amount) > 200; -- Keeps customers 3 (355.50) and 2 (250.00)

Many built-in functions go beyond aggregation - string, date, and math helpers you will use daily are covered in the SQL functions lesson.

9. Subqueries and a first look at CTEs

A subquery is a query nested inside another. It runs first, and its result feeds the outer query. A common use is filtering against a computed value, such as "orders above the average amount":

SQLSELECT order_id, amount FROM orders WHERE amount > ( SELECT AVG(amount) FROM orders ); -- Average is 157.10, so this returns orders 103 and 105

Subqueries also appear with IN, to check membership in another table's result:

SQL-- Customers who have placed at least one order SELECT name FROM customers WHERE customer_id IN ( SELECT DISTINCT customer_id FROM orders );

CTEs make complex queries readable

A Common Table Expression (CTE), written with WITH, is a named temporary result you can reference below. It does the same job as a subquery but reads top to bottom, which is far easier once queries grow. Here we build per-customer totals, then filter them:

SQLWITH customer_totals AS ( SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id ) SELECT c.name, t.total FROM customer_totals t JOIN customers c ON c.customer_id = t.customer_id WHERE t.total > 200 ORDER BY t.total DESC;
nametotal
Carla Ruiz355.50
Ben Osei250.00

The difference in practice: reach for a subquery when the nested logic is small and used once, and for a CTE when it is reused, deeply nested, or complex enough that a name makes the whole query easier to follow. Both compute the same answer; the CTE is purely about readability. You do not need to master either on day one, but knowing they exist means you will recognise the tool when a problem needs it.

10. A learning path: what to learn next

Not every topic takes the same effort to reach "comfortable and productive". The chart below is a rough guide to how much practice each area needs relative to the others - a higher bar means expect to spend more time before it feels natural. Use it to pace yourself rather than as an exact measure.

Relative effort to reach beginner competence
SELECT & WHERELow
ORDER BY / LIMITLow
INSERT / UPDATE / DELETEMed
GROUP BY & HAVINGMed
JOINsHigh
Subqueries & CTEsHigh

A sensible order to learn in:

Get fluent with SELECT and WHERE

Query the sample schema until filtering feels automatic. This is 80% of everyday SQL.

Add sorting, limiting, and DML

Practise ORDER BY, LIMIT, and safely inserting, updating, and deleting rows.

Master JOINs

Spend real time here. Once INNER and LEFT joins click, most reporting questions become writable.

Learn aggregation, then subqueries and CTEs

GROUP BY plus joins covers most analytics. Subqueries and CTEs handle the rest.

Keep a reference nearby while you practise - the SQL cheat sheet puts every clause on one page. When you want structured practice with feedback, the SQL beginner course walks through these same building blocks with exercises.

11. Common beginner mistakes

A few errors trip up nearly everyone at the start. Knowing them in advance saves hours of confusion:

  • Comparing to NULL with = instead of IS NULL - silently returns zero rows.
  • Running UPDATE or DELETE without a WHERE - changes every row in the table.
  • Assuming a query returns rows in order without an ORDER BY - the order is not guaranteed.
  • Selecting a non-grouped column alongside an aggregate without adding it to GROUP BY.
  • Using INNER JOIN when you meant LEFT JOIN - and quietly dropping rows that have no match.

Each of these, plus five more, is explained with wrong-and-right examples in the common SQL mistakes guide. Read it once you are comfortable with the basics above - you will recognise every trap.

That is the full beginner arc: you can now describe a schema, read and filter data, sort and limit it, change it safely, create well-constrained tables, join across tables, aggregate into summaries, and reach for subqueries and CTEs when a problem calls for them. The single best next step is practice - open the sample schema, invent questions, and write the queries to answer them.

Ready to write real SQL?

Take these fundamentals into a guided, exercise-driven course, or get one-on-one help when you get stuck.