SQL for Testers
Learn the SQL a manual tester really needs, from SELECT and WHERE to JOINs, GROUP BY and NULL traps, then find real bugs in a practice food delivery database right in your browser.
It’s Thursday afternoon at FoodDash, a food delivery app. A support ticket comes in: “I was charged €33.50, but my receipt only adds up to €30.” Your lead pings you: “The app looks fine to me. Can you check order 1007 in the database and tell me what was actually saved?”
If you know a little SQLSQLThe language used to read and change data in relational databases, like SELECT * FROM orders.Open in glossary →, that’s a five-minute job. Without it, you’re stuck waiting for a developer.
That’s why “basic SQL” shows up in most junior QA job ads, and why many interviews include a small “write a query” task. The good news: testers need only a small, very learnable part of SQL. This lesson teaches exactly that part, and ends with a practice databaseDatabaseAn organized store of an app's data, such as users, orders and payments, kept on a server.Open in glossary → full of real-looking bugs for you to find.
Why testers use SQL
The screens you test are only a window onto data stored on a server. SQL lets you look behind the window. Testers use it for four everyday jobs:
1. Check what the app really saved
The screen says “Order placed”. Is there a new row with the right total, status and coupon?
2. Find test data
”I need a user with a canceled order and no city.” One query beats clicking through 200 accounts.
3. Set up preconditions
Confirm that the state your preconditionsPreconditionSomething that must already be true before you can start a test.Open in glossary → need really exists, or create it in your own test database.
4. Investigate bugs
Is the wrong price a display bug or a saving bug? The database answers that, and makes your bug report much sharper.
The essentials
Tables, rows and keys
A database stores data in tablesTable (database)A set of rows and columns in a database that holds one kind of thing, like users or orders.Open in glossary →. Each row is one thing (one user, one order) and each column is one property (email, total, status). Every table has a primary keyPrimary keyA column (usually id) whose value uniquely identifies each row in a table.Open in glossary →, usually id, that is unique per row. Tables point to each other with foreign keysForeign keyA column that points to the primary key of a row in another table, like orders.user_id → users.id.Open in glossary →: orders.user_id holds the id of the user who placed the order.
The clauses you’ll use most
SELECT id, status, total -- which columns
FROM orders -- which table
WHERE status = 'delivered' -- which rows
AND total > 20
ORDER BY total DESC -- sort, highest first
LIMIT 10; -- only the first 10They always appear in this order. Text goes in single quotes; numbers don’t.
| You want… | Write |
|---|---|
| Exact match / not equal | city = 'Berlin', status <> 'canceled' |
| A pattern | email LIKE '%@mail.test' |
| One of a list | status IN ('preparing', 'on_the_way') |
| A range (inclusive) | total BETWEEN 10 AND 20 |
| An empty value | city IS NULL (never = NULL) |
Counting and grouping
SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status;COUNT, SUM, AVG, MIN and MAX summarize rows. GROUP BY gives one result row per group. WHERE filters rows before grouping; HAVING filters groups after it, for example HAVING COUNT(*) > 1.
Joining tables
A JOIN (SQL)JOIN (SQL)Combining rows from two tables that belong together, for example each order with its customer.Open in glossary → combines related rows. An INNER JOIN keeps only rows with a match on both sides. A LEFT JOIN keeps every row from the first table and fills the missing side with NULLNULLA database marker for "no value" or "unknown". It's not zero and not an empty string.Open in glossary →, which is perfect for finding things that are missing:
-- Orders that have no items (a bug!)
SELECT o.id
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE oi.id IS NULL;Three bug-hunting recipes
-- Duplicate emails, ignoring capitalization
SELECT LOWER(email), COUNT(*) FROM users
GROUP BY LOWER(email) HAVING COUNT(*) > 1;
-- Order items whose order doesn't exist (orphan rows)
SELECT oi.* FROM order_items oi
LEFT JOIN orders o ON o.id = oi.order_id
WHERE o.id IS NULL;
-- Orders whose total doesn't match their items
SELECT o.id, o.total, SUM(oi.quantity * oi.unit_price) AS items_total
FROM orders o JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.total
HAVING ROUND(SUM(oi.quantity * oi.unit_price), 2) <> o.total;Switch to Detailed at the top of the page for the full walkthrough: every operator with examples, aggregates, INNER vs LEFT JOIN with a live demo, NULL traps, tester recipes, preparing test data safely, and real tools. Or jump straight to the practice database below.
The basics: databases, tables and keys
Think of a database as a set of very strict spreadsheets. Each sheet is a table with a fixed list of columns, and every column has a type: whole numbers (INTEGER), decimals (REAL, DECIMAL), text (TEXT, VARCHAR), dates and so on.
The practice database in this lesson is a simplified FoodDash, the same food delivery app you test in the Capstone. It has five tables:
users
🔑 id, name, email, city, signed_up
restaurants
🔑 id, name, cuisine, city, rating
coupons
🔑 id, code, percent, once_per_user, valid_until
orders
🔑 id, user_id → users, restaurant_id → restaurants, status, total, coupon_id → coupons, created_at
order_items
🔑 id, order_id → orders, dish, quantity, unit_price
- The primary key (🔑) uniquely identifies a row. There is exactly one order with
id = 1007. - A foreign key (→) stores another table’s primary key.
orders.user_id = 2means “this order belongs to user 2”. One user can have many orders, and one order can have many items. - A column may allow NULL, meaning “no value”. In FoodDash,
users.cityandorders.coupon_idcan be NULL.
SELECT: reading data
Every read starts with SELECT (which columns) and FROM (which table):
SELECT * FROM restaurants; -- every column
SELECT name, city FROM restaurants; -- only these two
SELECT name AS restaurant, rating FROM restaurants; -- rename a column in the result
SELECT DISTINCT city FROM restaurants; -- each city once*is handy for a first look, but on real tables with 40 columns, pick the ones you need.ASgives a column a friendlier name in the result. It doesn’t change the table.DISTINCTremoves duplicate result rows.SELECT DISTINCT city FROM restaurantslists Belgrade, Novi Sad and Berlin once each.- End each query with a semicolon
;. Keywords aren’t case-sensitive (selectworks), but writing them in capitals makes queries easier to read.
WHERE: picking the rows you need
WHERE keeps only the rows where a condition is true.
| Operator | Example | Meaning |
|---|---|---|
= | status = 'delivered' | Equal |
<> (or !=) | status <> 'canceled' | Not equal |
> < >= <= | total >= 20 | Greater / smaller |
LIKE | email LIKE '%@mail.test' | Matches a pattern |
IN | status IN ('preparing', 'on_the_way') | Any value in the list |
BETWEEN | total BETWEEN 10 AND 20 | In a range, both ends included |
IS NULL | coupon_id IS NULL | Has no value |
IS NOT NULL | coupon_id IS NOT NULL | Has a value |
Combine conditions with AND, OR and NOT. When you mix AND and OR, use brackets, because AND is evaluated first:
-- Delivered or on the way, AND over 20 euros
SELECT id, status, total
FROM orders
WHERE (status = 'delivered' OR status = 'on_the_way')
AND total > 20;Without the brackets you would get every on-the-way order over 20 plus every delivered order of any size. That’s a classic wrong-result bug in testers’ own queries.
LIKE patterns
% matches any number of characters (even none) and _ matches exactly one.
| Pattern | Matches |
|---|---|
'Mia%' | Starts with “Mia” |
'%@mail.test' | Ends with “@mail.test” |
'%pizza%' | Contains “pizza” anywhere |
'_ana%' | Any first letter, then “ana”: Dana, Hana, Jana… |
Dates
In most databases, you write dates as text in the ISO format 'YYYY-MM-DD', and comparisons work as expected:
SELECT id, created_at
FROM orders
WHERE created_at >= '2025-09-10';Watch out with BETWEEN on date-time columns: created_at BETWEEN '2025-09-01' AND '2025-09-07' stops at midnight at the start of September 7, so orders placed later that day are missed. Use created_at < '2025-09-08' for the upper limit instead.
ORDER BY and LIMIT
Without ORDER BY, the database may return rows in any order, and that order can change between runs. If the order matters, say so:
SELECT id, total
FROM orders
ORDER BY total DESC, id ASC -- highest total first; ties sorted by id
LIMIT 5; -- the top fiveASC (smallest first) is the default. LIMIT works in PostgreSQL, MySQL and SQLite. SQL Server uses SELECT TOP 5 ..., and both SQL Server and Oracle support OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY (Oracle also allows the shorter FETCH FIRST 5 ROWS ONLY).
Counting and totals: aggregates, GROUP BY and HAVING
Aggregate functions turn many rows into one value:
| Function | Example | Returns |
|---|---|---|
COUNT(*) | SELECT COUNT(*) FROM orders | Number of rows (15) |
COUNT(column) | SELECT COUNT(coupon_id) FROM orders | Rows where that column is not NULL |
SUM | SELECT SUM(total) FROM orders WHERE status = 'delivered' | The sum |
AVG | SELECT AVG(rating) FROM restaurants | The average |
MIN / MAX | SELECT MIN(created_at), MAX(created_at) FROM orders | The smallest / largest |
GROUP BY
GROUP BY splits the rows into groups and runs the aggregate once per group:
SELECT status, COUNT(*) AS orders, SUM(total) AS revenue
FROM orders
GROUP BY status;
| status | orders | revenue |
|---|---|---|
| canceled | 2 | 22.9 |
| delivered | 10 | 201.4 |
| on_the_way | 1 | 16.9 |
| preparing | 2 | 27.1 |
Rule of thumb: every column in SELECT must either be inside an aggregate or listed in GROUP BY. (SQLite and older MySQL setups let you break this rule and quietly return a random value from the group. Don’t rely on it.)
This is exactly how you test a dashboard: if the admin panel says “11 delivered orders” and your query says 10, someone is counting wrong.
WHERE vs HAVING
WHERE filters rows before they are grouped. HAVING filters groups after the aggregates are calculated.
HAVING for conditions on aggregates
SELECT user_id, COUNT(*) AS orders
FROM orders
WHERE status <> 'canceled' -- rows
GROUP BY user_id
HAVING COUNT(*) >= 2; -- groupsUsers with at least two non-canceled orders.
Aggregates in WHERE
SELECT user_id, COUNT(*)
FROM orders
WHERE COUNT(*) >= 2
GROUP BY user_id;Error: “misuse of aggregate”. When WHERE runs, nothing has been counted yet.
JOINs: combining tables
Real questions span several tables: “Which customer placed this order at which restaurant?” A JOIN matches rows using the keys:
SELECT o.id, u.name AS customer, r.name AS restaurant, o.total
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN restaurants r ON r.id = o.restaurant_id
WHERE o.id = 1007;orders ogives the table a short alias, so you can writeo.idinstead oforders.id.- The
ONcondition says how rows belong together: foreign key = primary key. - When two tables have a column with the same name (
name,id), you must prefix it, or you’ll get an “ambiguous column name” error.
INNER JOIN vs LEFT JOIN
JOIN on its own means INNER JOIN: only rows that have a partner on both sides. A LEFT JOIN keeps every row from the left (first) table, and when there’s no partner, the right side’s columns are NULL. Try the three versions:
orders
| id | total |
|---|---|
| 1001 | 21.40 |
| 1009 | 9.50 |
| 1010 | 13.40 |
order_items
| id | order_id | dish |
|---|---|---|
| 1 | 1001 | Margherita |
| 2 | 1001 | Diavola |
| 17 | 1009 | Margherita |
SELECT o.id, o.total, oi.dish FROM orders o JOIN order_items oi ON oi.order_id = o.id;
Result: 3 rows
| id | total | dish |
|---|---|---|
| 1001 | 21.40 | Margherita |
| 1001 | 21.40 | Diavola |
| 1009 | 9.50 | Margherita |
Only orders that have a matching item. Order 1010 has none, so it silently disappears from the result.
That difference is the heart of many tester queries:
- Find what’s missing:
LEFT JOIN+WHERE right_side.id IS NULLfinds orders without items, users who never ordered, restaurants with no orders. - Beware of hidden rows: an
INNER JOINsilently drops the broken order. If your “orders per restaurant” report uses an inner join, a restaurant with zero orders won’t appear at all instead of showing 0.
-- Users who have never placed an order
SELECT u.id, u.name
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;NULL: the trap behind many wrong results
NULL means “no value” or “unknown”. It isn’t 0, it isn’t an empty string '', and it doesn’t behave like a normal value. These are the traps that catch testers (and developers) all the time:
| Trap | What happens | Do this instead |
|---|---|---|
WHERE coupon_id = NULL | Zero rows, no error | WHERE coupon_id IS NULL |
WHERE city <> 'Berlin' | Rows with a NULL city are skipped too (5 rows instead of 7) | Add OR city IS NULL |
COUNT(city) vs COUNT(*) | COUNT(city) skips NULLs (8 instead of 10) | Use COUNT(*) to count rows |
AVG(rating) | Ignores NULL ratings, it doesn’t treat them as 0 | Decide which one the requirement means |
NOT IN (SELECT coupon_id FROM orders) | Returns nothing if the list contains a NULL | Use NOT EXISTS or a LEFT JOIN |
total + NULL | The result is NULL | COALESCE(discount, 0) replaces NULL with 0 |
Typical tester queries
These recipes cover most of what testers do with SQL day to day. In the practice database below, each one finds a real bug.
Verify that the UI saved the right data
You place an order in the app, then check exactly what was stored:
- Find the record. Use something you know from the UI, like the order number on the confirmation screen:
SELECT * FROM orders WHERE id = 1007;If you don’t have an id, use your test user plus time:WHERE user_id = 2 ORDER BY created_at DESC LIMIT 1. - Check every field the screen showed. Status, total, coupon, timestamps. Then check the fields the screen didn’t show, like
coupon_idorcreated_at. Is the time zone right? - Check the related rows.
SELECT * FROM order_items WHERE order_id = 1007;Are all dishes there, with the right quantities and prices? - Check that the numbers agree. Does the total match the sum of the items? Did exactly one order get created, not two after a double tap?
Find duplicates
SELECT LOWER(email) AS email, COUNT(*) AS accounts
FROM users
GROUP BY LOWER(email)
HAVING COUNT(*) > 1;GROUP BY ... HAVING COUNT(*) > 1 is the universal duplicate finder. Group by whatever should be unique: an email, a coupon code, an order number, a payment id. Use LOWER() or TRIM() when duplicates may hide behind capitals or spaces.
Find orphan rows
SELECT oi.*
FROM order_items oi
LEFT JOIN orders o ON o.id = oi.order_id
WHERE o.id IS NULL;Orphans are rows whose foreign key points to nothing, often left behind when something was deleted. Check them after delete features, migrations and imports.
Find totals that don’t add up
SELECT o.id, o.total, ROUND(SUM(oi.quantity * oi.unit_price), 2) AS items_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.total
HAVING ROUND(SUM(oi.quantity * oi.unit_price), 2) <> o.total;Find rule violations
Business rules make great queries. “WELCOME10 may be used once per user”:
SELECT o.user_id, c.code, COUNT(*) AS times_used
FROM orders o
JOIN coupons c ON c.id = o.coupon_id
WHERE c.once_per_user = 1
GROUP BY o.user_id, c.code
HAVING COUNT(*) > 1;Other rules worth a query: no delivered order without a delivery time, no coupon used after valid_until, no negative quantities, no order total of 0.
Database checks in your test cases
A database check fits into a normal test case as part of the expected result:
TC-DB-01Placed order is stored with the correct total and itemspositivehigh
Preconditions
- Signed in as test user luka.petrovic@mail.test
- Cart is empty
- Read-only access to the test database
Steps
- Add 2 × Spicy tuna roll (10.20) and 2 × Salmon nigiri (4.80) from Sushi Zen Garden
- Place the order and note the order number
- Run SELECT * FROM orders WHERE id = <order number>
- Run SELECT * FROM order_items WHERE order_id = <order number>
Expected result
The app shows a total of 30.00. orders has exactly one new row with status 'preparing' and total 30.00. order_items has exactly 2 rows that add up to 30.00.
TC-DB-02Sign-up rejects an email that differs only in capitalizationnegativehigh
Preconditions
- An account exists for mia.novak@mail.test
Steps
- Open Sign up
- Enter Mia.Novak@mail.test and a valid password
- Tap Create account
- Run SELECT id, email FROM users WHERE LOWER(email) = 'mia.novak@mail.test'
Expected result
The app shows 'An account with this email already exists'. The query still returns exactly 1 row.
TC-DB-03Once-per-user coupon can't be used a second timenegativemedium
Preconditions
- The test user has one delivered order that used WELCOME10
Steps
- Add any dish to the cart
- Enter coupon WELCOME10 at checkout
- Try to place the order
- Run the coupon-usage query grouped by user_id and code
Expected result
The app shows 'You have already used this coupon' and no order is created. WELCOME10 still appears only once for this user.
Tip: click a test case to expand it.
Preparing test data: INSERT, UPDATE and DELETE
Sometimes the fastest way to reach a precondition (“a user with an expired coupon”) is to create it directly. That’s fine only on a database that is yours: a local copy, a personal test environment, or a shared test database where your team explicitly allows it.
-- Create a test coupon that has already expired
INSERT INTO coupons (id, code, percent, once_per_user, valid_until)
VALUES (90, 'QA_EXPIRED', 10, 1, '2025-01-31');
-- Move one test order to another status
UPDATE orders SET status = 'canceled' WHERE id = 1013;
-- Remove the test coupon again
DELETE FROM coupons WHERE id = 90;You can try all three statements in the playground below. It’s your own copy, and Reset database undoes everything.
Tools you’ll meet at work
- DBeaver (free, works with almost every database), pgAdmin (PostgreSQL), MySQL Workbench (MySQL), SQL Server Management Studio (SQL Server) and DataGrip (paid, by JetBrains).
- Ask for a read-only database user for each environmentEnvironmentA setup of servers, data and configuration where the software runs, such as dev, staging or production.Open in glossary →. DBeaver also has a “Read-only connection” option in the connection settings: switch it on for anything shared.
- Many teams also have an admin panel that shows the same data. It’s convenient, but it may hide fields or apply its own logic. SQL shows you the raw truth.
- Save useful queries in a shared file or wiki page. A team “query cookbook” pays off quickly.
Common mistakes
Practice: find the bugs in FoodDash
Time to use it. Below is a real SQLite database running in your browser, filled with FoodDash data. Some of that data is broken in ways that real apps get broken. Work through the 10 exercises: the first five practice the basics, the last five are bug hunts.
- Press Run (or Ctrl/⌘ + Enter) to see your result, and Check answer to compare it with the expected result.
- Column names and column order don’t matter. Row order only matters when the task says so.
- Want to experiment with
UPDATEorDELETE? Go ahead: it’s your private copy, and Reset database restores it. Checks always use the original data.
FoodDash practice DB
SQLite, running in your browser
Restaurants in Belgrade
Show the name and rating of every restaurant in Belgrade.
Return: name rating (column names and order don't matter)
Before you run a query at work
SQL safety and accuracy checklist
0 of 9 checked
Check your understanding
Quick quiz
0/6 answered · 0 correct
1.You want users who have no city saved. Which condition is correct?
2.Which query lists the orders that have no items?
3.What does HAVING do that WHERE can't?
4.Which query finds email addresses used by more than one account?
5.A ticket says order 1013 has the wrong status. You have access to the shared staging database. What should you do first?
6.Why might SUM(quantity * unit_price) show 14.100000000000001 instead of 14.1?
Key takeaways
- Testers use SQL to verify what the app saved, find test data, check preconditions and investigate bugs.
- Read, don’t write: no UPDATE or DELETE on shared or production databases. Use a read-only user and your own test database for experiments.
SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMITis the order to remember. Text goes in single quotes.- WHERE filters rows, HAVING filters groups.
GROUP BY … HAVING COUNT(*) > 1finds duplicates. - INNER JOIN keeps matches only; LEFT JOIN +
IS NULLfinds what’s missing: orders without items, orphan rows, users who never ordered. - NULL is not a value: use
IS NULL, and remember that<>,COUNT(column)andNOT INtreat it in surprising ways. - Paste your query and its result into the bug report. It makes the bug easy to reproduce and proves where it lives.
Finished this page?Nice work, this page is complete!
Mark it as done to track your progress. It's saved in this browser, no account needed.
Its review cards are now in Bites ⚡