In Part 1 of this series, I covered creating tables and connecting them with primary and foreign keys. In Part 2, I practiced querying a single table using SELECT, WHERE, GROUP BY, and HAVING.
Part 3 is where relational databases finally started feeling relational to me. Before learning joins, tables in a database just felt like separate spreadsheets sitting next to each other. Joins are how you actually stitch those tables together at query time. Alongside INNER JOIN, LEFT JOIN, RIGHT JOIN, and how to simulate a FULL JOIN in MySQL using UNION, this post also covers ALTER TABLE, UPDATE, and DELETE.
Setting Up Two Simple Tables
To see how different joins behave, it helps to set up two small tables that intentionally don’t line up 100%, because real-world data almost never does.
First, here is an orders table with three orders:
CREATE TABLE orders(
o_id INT,
cust_id INT,
price INT
);
INSERT INTO orders
VALUES
(1, 101, 1000),
(2, 201, 1100),
(3, 501, 1200);
And here is a customers table with three customers:
CREATE TABLE customers(
id INT,
name VARCHAR(100),
email VARCHAR(100)
);
INSERT INTO customers
VALUES
(101, 'love', 'aa'),
(201, 'ansh', 'bb'),
(301, 'lamba', 'cc');
Notice the deliberate mismatch between the two tables:
ordershas a row withcust_id = 501, but there is no customer501in thecustomerstable.customershas a row withid = 301(lamba), but that customer has never placed an order inorders.
That small mismatch makes it immediately obvious what each type of join keeps and what it drops.
INNER JOIN: Only the Matches
An INNER JOIN returns the intersection of two tables. A row only appears in the result if the joining column matches in both tables.
SELECT *
FROM orders o
INNER JOIN customers c
ON o.cust_id = c.id;
Here, o and c are table aliases for orders and customers so we can write o.cust_id = c.id instead of spelling out the full table names every time.
With our data, this query returns 2 rows:
| o_id | cust_id | price | id | name | |
|---|---|---|---|---|---|
| 1 | 101 | 1000 | 101 | love | aa |
| 2 | 201 | 1100 | 201 | ansh | bb |
- Order
1(cust_id = 101) matches customer101(love). - Order
2(cust_id = 201) matches customer201(ansh). - Order
3(cust_id = 501) is dropped completely because no customer withid = 501exists. - Customer
301(lamba) is also dropped because they have no matching row inorders.
INNER JOIN is strict: if there is no match on both sides, the row does not show up at all.
LEFT JOIN: Everything From the Left Table
A LEFT JOIN keeps every row from the left table (orders, since it appears first after FROM), whether or not it finds a matching row in the right table (customers).
SELECT *
FROM orders o
LEFT JOIN customers c
ON o.cust_id = c.id;
This returns 3 rows, preserving every order in orders:
| o_id | cust_id | price | id | name | |
|---|---|---|---|---|---|
| 1 | 101 | 1000 | 101 | love | aa |
| 2 | 201 | 1100 | 201 | ansh | bb |
| 3 | 501 | 1200 | NULL | NULL | NULL |
Orders 1 and 2 pull in their matching customer details as usual. Order 3 (cust_id = 501) has no matching customer, but instead of dropping the order, SQL keeps the row from orders and fills the missing customers columns (id, name, email) with NULL.
RIGHT JOIN: Everything From the Right Table
A RIGHT JOIN is the mirror image of a LEFT JOIN. It keeps every row from the right table (customers), whether or not a matching row exists in the left table (orders).
SELECT *
FROM orders o
RIGHT JOIN customers c
ON o.cust_id = c.id;
This also returns 3 rows, but now anchored to customers:
| o_id | cust_id | price | id | name | |
|---|---|---|---|---|---|
| 1 | 101 | 1000 | 101 | love | aa |
| 2 | 201 | 1100 | 201 | ansh | bb |
| NULL | NULL | NULL | 301 | lamba | cc |
Customers 101 and 201 match orders 1 and 2. Customer 301 (lamba) never placed an order, so the order columns (o_id, cust_id, price) come back as NULL while the customer record stays intact.
How to Do a FULL JOIN in MySQL Using UNION
One thing that surprised me when practicing in MySQL is that MySQL does not have a built-in FULL OUTER JOIN (or FULL JOIN) keyword, even though databases like PostgreSQL and SQL Server support it directly.
A full join is supposed to return everything from both tables at once: all matched rows, all unmatched rows from the left table, and all unmatched rows from the right table.
In MySQL, the standard way to get a FULL JOIN is to combine a LEFT JOIN and a RIGHT JOIN with UNION:
SELECT *
FROM orders o
LEFT JOIN customers c
ON o.cust_id = c.id
UNION
SELECT *
FROM orders o
RIGHT JOIN customers c
ON o.cust_id = c.id;
Here is how UNION makes this work:
- The
LEFT JOINquery returns orders1,2, and3(withNULLcustomer columns on order3). - The
RIGHT JOINquery returns customers101,201, and301(withNULLorder columns on customer301). - Because both
SELECT *queries listordersfirst andcustomerssecond, both result sets have the exact same columns in the exact same order.UNIONstacks the two results together and automatically removes duplicate rows (unlikeUNION ALL, which keeps duplicates).
That gives us 4 rows total:
| o_id | cust_id | price | id | name | |
|---|---|---|---|---|---|
| 1 | 101 | 1000 | 101 | love | aa |
| 2 | 201 | 1100 | 201 | ansh | bb |
| 3 | 501 | 1200 | NULL | NULL | NULL |
| NULL | NULL | NULL | 301 | lamba | cc |
The two matched rows (101 and 201) appeared in both the LEFT JOIN and RIGHT JOIN, and UNION deduplicated them so each appears once alongside the unmatched order (501) and the unmatched customer (301).
Adding a Primary Key Later with ALTER TABLE
While working with these two tables, I realized I hadn’t declared id as a primary key when I first created customers. Instead of dropping and recreating the table, you can add a primary key to an existing table with ALTER TABLE:
ALTER TABLE customers
ADD PRIMARY KEY(id);
As long as the existing values in id are unique and contain no NULL values, MySQL promotes the column to a PRIMARY KEY in place. It’s a nice reminder that schemas don’t have to be frozen on day one; ALTER TABLE lets you tighten constraints as your schema evolves.
UPDATE: Modifying Existing Rows Safely
While INSERT adds brand-new rows, UPDATE modifies values inside rows that already exist in a table.
UPDATE customers
SET name = 'Niraj'
WHERE id = 101;
This updates the name column for customer 101 from 'love' to 'Niraj'. We can verify the change right away:
SELECT * FROM customers;
The single most important clause in any UPDATE statement is WHERE. If you run:
UPDATE customers
SET name = 'Niraj';
without a WHERE clause, SQL will happily overwrite name = 'Niraj' across every single row in the customers table. MySQL Workbench actually enables a “safe updates” mode by default to block UPDATE statements that lack a WHERE clause on a key column, specifically because forgetting WHERE on an UPDATE is one of the most common (and painful) mistakes in SQL.
DELETE: Removing Rows Without Wiping the Table
DELETE follows the exact same pattern as UPDATE, except instead of changing column values, it removes entire rows from the table:
DELETE FROM customers
WHERE id = 101;
This permanently removes the row where id = 101. Just like with UPDATE, omitting the WHERE clause is dangerous:
DELETE FROM customers;
Running DELETE FROM customers; without a WHERE clause deletes every row in the table, leaving you with an empty customers table and no confirmation prompt.
One habit I’ve picked up while practicing SQL is to always write and run the SELECT version of a query first:
-- Step 1: Preview the exact rows that match the condition
SELECT * FROM customers
WHERE id = 101;
-- Step 2: Once confirmed, swap SELECT * for DELETE (or UPDATE)
DELETE FROM customers
WHERE id = 101;
Testing your WHERE condition with SELECT * first takes five seconds and guarantees you know which rows you are about to modify or delete.
Coming Up in Part 4
Now that we can filter, group, and join tables together, the next step is asking multi-step questions where one query depends on the result of another. In Part 4, I’ll get into subqueries and window functions (including how to solve the “top N per group” problem that LIMIT alone couldn’t handle in Part 2).
