Skip to content
Bugwise
Menu
Tutorials

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.

beginner Mobile & web
Short on time? Switch to the quick version.

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 10

They always appear in this order. Text goes in single quotes; numbers don’t.

You want…Write
Exact match / not equalcity = 'Berlin', status <> 'canceled'
A patternemail LIKE '%@mail.test'
One of a liststatus IN ('preparing', 'on_the_way')
A range (inclusive)total BETWEEN 10 AND 20
An empty valuecity 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 = 2 means “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.city and orders.coupon_id can 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.
  • AS gives a column a friendlier name in the result. It doesn’t change the table.
  • DISTINCT removes duplicate result rows. SELECT DISTINCT city FROM restaurants lists Belgrade, Novi Sad and Berlin once each.
  • End each query with a semicolon ;. Keywords aren’t case-sensitive (select works), 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.

OperatorExampleMeaning
=status = 'delivered'Equal
<> (or !=)status <> 'canceled'Not equal
> < >= <=total >= 20Greater / smaller
LIKEemail LIKE '%@mail.test'Matches a pattern
INstatus IN ('preparing', 'on_the_way')Any value in the list
BETWEENtotal BETWEEN 10 AND 20In a range, both ends included
IS NULLcoupon_id IS NULLHas no value
IS NOT NULLcoupon_id IS NOT NULLHas 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.

PatternMatches
'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 five

ASC (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:

FunctionExampleReturns
COUNT(*)SELECT COUNT(*) FROM ordersNumber of rows (15)
COUNT(column)SELECT COUNT(coupon_id) FROM ordersRows where that column is not NULL
SUMSELECT SUM(total) FROM orders WHERE status = 'delivered'The sum
AVGSELECT AVG(rating) FROM restaurantsThe average
MIN / MAXSELECT MIN(created_at), MAX(created_at) FROM ordersThe 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;
statusordersrevenue
canceled222.9
delivered10201.4
on_the_way116.9
preparing227.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;         -- groups

Users 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 o gives the table a short alias, so you can write o.id instead of orders.id.
  • The ON condition 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

idtotal
100121.40
10099.50
101013.40

order_items

idorder_iddish
11001Margherita
21001Diavola
171009Margherita
SELECT o.id, o.total, oi.dish
FROM orders o
JOIN order_items oi ON oi.order_id = o.id;

Result: 3 rows

idtotaldish
100121.40Margherita
100121.40Diavola
10099.50Margherita

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 NULL finds orders without items, users who never ordered, restaurants with no orders.
  • Beware of hidden rows: an INNER JOIN silently 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:

TrapWhat happensDo this instead
WHERE coupon_id = NULLZero rows, no errorWHERE 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 0Decide which one the requirement means
NOT IN (SELECT coupon_id FROM orders)Returns nothing if the list contains a NULLUse NOT EXISTS or a LEFT JOIN
total + NULLThe result is NULLCOALESCE(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:

  1. 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.
  2. Check every field the screen showed. Status, total, coupon, timestamps. Then check the fields the screen didn’t show, like coupon_id or created_at. Is the time zone right?
  3. Check the related rows. SELECT * FROM order_items WHERE order_id = 1007; Are all dishes there, with the right quantities and prices?
  4. 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 items

Preconditions

  • Signed in as test user luka.petrovic@mail.test
  • Cart is empty
  • Read-only access to the test database

Steps

  1. Add 2 × Spicy tuna roll (10.20) and 2 × Salmon nigiri (4.80) from Sushi Zen Garden
  2. Place the order and note the order number
  3. Run SELECT * FROM orders WHERE id = <order number>
  4. 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 capitalization

Preconditions

  • An account exists for mia.novak@mail.test

Steps

  1. Open Sign up
  2. Enter Mia.Novak@mail.test and a valid password
  3. Tap Create account
  4. 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 time

Preconditions

  • The test user has one delivered order that used WELCOME10

Steps

  1. Add any dish to the cart
  2. Enter coupon WELCOME10 at checkout
  3. Try to place the order
  4. 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 UPDATE or DELETE? 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

Loading the database engine (about 700 KB, once)…
0 / 10 solved
Exercise 1Easy

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. 1.You want users who have no city saved. Which condition is correct?

  2. 2.Which query lists the orders that have no items?

  3. 3.What does HAVING do that WHERE can't?

  4. 4.Which query finds email addresses used by more than one account?

  5. 5.A ticket says order 1013 has the wrong status. You have access to the shared staging database. What should you do first?

  6. 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 … LIMIT is the order to remember. Text goes in single quotes.
  • WHERE filters rows, HAVING filters groups. GROUP BY … HAVING COUNT(*) > 1 finds duplicates.
  • INNER JOIN keeps matches only; LEFT JOIN + IS NULL finds 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) and NOT IN treat 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 ⚡
Esc

Type to search the whole site.