Practice SQL online with a free interactive SQL playground. Write and run real SQL queries against realistic relational datasets, explore database schemas, solve SQL practice problems, and build the skills you need for SQL interviews.
The editor above is connected to a real SQLite database with 8 related tables and 1,476 rows: an online store with customers, orders, products, payments and reviews, plus a company’s employees and departments. Nothing needs installing, and you can run queries without signing up.
Reading a query and writing one are different skills. You get better at SQL by typing a query, running it, and finding out that the result is not what you expected. This playground is built for that loop: your query is executed by SQLite and the actual rows come back, or the actual error message does.
The tables are linked by foreign keys, so the same data supports single-table filtering, multi-table joins and analytical queries. To see how the tables connect, open the interactive database schema.
| Table | Rows | What it holds |
|---|---|---|
users | 150 | Customers: name, email, city, country, signup date, status |
orders | 200 | Orders: customer, total, discount, status, order and ship dates, shipping city |
order_items | 496 | Order lines: order, product, quantity, unit price, subtotal |
products | 120 | Catalogue: name, category, price, cost, stock, SKU, status, rating |
payments | 200 | Payments: order, customer, amount, method, status, paid date |
reviews | 180 | Product reviews: product, customer, 1 to 5 rating, comment, review date |
employees | 120 | Staff: department, title, salary, hire date, manager, city |
departments | 10 | Departments: name, location, headcount, budget |
The database is read-only: queries start with SELECT or WITH, results are capped at 500 rows, and the data is the same every time you come back. Orders are dated from January 2022 to December 2024, so filter on fixed dates instead of today’s date.
Each question below comes with a working answer. Every query on this page has been run against the playground’s database, and each one has a link that loads it into the editor and runs it. Try writing your own version first, then compare.
One table at a time: choosing columns, filtering rows, sorting and limiting. If any of these are new, the lessons on the WHERE clause, ORDER BY, LIMIT and DISTINCT explain them step by step.
Covers: SELECT, WHERE, ORDER BY
Pick the columns you need, keep only the rows that match a condition, and sort the result. Most reports start exactly like this.
SELECT first_name, last_name, email, city
FROM users
WHERE city = 'Phoenix'
ORDER BY last_name;▶ Run this query in the playgroundCovers: DISTINCT
The products table stores a category on every row. DISTINCT collapses the repeats so each category appears once.
SELECT DISTINCT category
FROM products
ORDER BY category;▶ Run this query in the playgroundCovers: WHERE with AND, ORDER BY, LIMIT
Two conditions joined with AND, a two-level sort, and LIMIT to keep the top ten.
SELECT name, category, price, rating
FROM products
WHERE status = 'active'
AND price < 500
ORDER BY rating DESC, price
LIMIT 10;▶ Run this query in the playgroundCovers: ORDER BY DESC, LIMIT
Dates are stored as YYYY-MM-DD text, so sorting them as text also sorts them chronologically.
SELECT id, user_id, total, status, order_date
FROM orders
ORDER BY order_date DESC
LIMIT 10;▶ Run this query in the playgroundSummarising and combining tables. These questions use GROUP BY with aggregate functions, HAVING, SQL JOINs, CASE expressions and subqueries.
Covers: GROUP BY, COUNT, SUM
One output row per order status, with the number of orders and their combined value.
SELECT status,
COUNT(*) AS orders,
ROUND(SUM(total), 2) AS revenue
FROM orders
GROUP BY status
ORDER BY orders DESC;▶ Run this query in the playgroundCovers: LEFT JOIN, IS NULL
A LEFT JOIN keeps every customer. Where no order matched, the order columns are NULL, and that is the filter.
SELECT u.id, u.first_name, u.last_name, u.email
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL
ORDER BY u.last_name;▶ Run this query in the playgroundCovers: JOIN, SUM, GROUP BY, LIMIT
Join the line items to the product catalogue, then total the quantity and revenue for each product.
SELECT p.name,
SUM(oi.quantity) AS units_sold,
ROUND(SUM(oi.subtotal), 2) AS revenue
FROM products p
JOIN order_items oi ON oi.product_id = p.id
GROUP BY p.id, p.name
ORDER BY units_sold DESC
LIMIT 5;▶ Run this query in the playgroundCovers: HAVING, scalar subquery
WHERE cannot see an aggregate, so the comparison against the overall average price goes in HAVING.
SELECT category,
COUNT(*) AS products,
ROUND(AVG(price), 2) AS avg_price
FROM products
GROUP BY category
HAVING AVG(price) > (SELECT AVG(price) FROM products)
ORDER BY avg_price DESC;▶ Run this query in the playgroundCovers: CASE, GROUP BY
CASE turns a number into a label, and the label can then be grouped like any other column.
SELECT CASE
WHEN price < 500 THEN 'Under $500'
WHEN price < 1000 THEN '$500 to $999'
ELSE '$1,000 and up'
END AS price_band,
COUNT(*) AS products,
MIN(price) AS lowest_price,
MAX(price) AS highest_price
FROM products
GROUP BY price_band
ORDER BY lowest_price;▶ Run this query in the playgroundCovers: strftime, date filter, AVG
strftime is the SQLite way to cut a date down to its month. The date range is fixed because the sample orders run from January 2022 to December 2024.
SELECT strftime('%Y-%m', order_date) AS month,
COUNT(*) AS orders,
ROUND(AVG(total), 2) AS avg_order_value
FROM orders
WHERE order_date >= '2024-01-01'
AND order_date < '2025-01-01'
GROUP BY month
ORDER BY month;▶ Run this query in the playgroundMulti-step analysis: common table expressions, window functions, ranking with RANK and DENSE_RANK, row-to-row comparisons with LAG and LEAD, and correlated subqueries.
Covers: CTE, NTILE
The CTE totals each customer once. The outer query then ranks those totals into four equal-sized groups.
WITH customer_totals AS (
SELECT user_id,
COUNT(*) AS order_count,
SUM(total) AS lifetime_value
FROM orders
GROUP BY user_id
)
SELECT u.first_name || ' ' || u.last_name AS customer,
ct.order_count,
ROUND(ct.lifetime_value, 2) AS lifetime_value,
NTILE(4) OVER (ORDER BY ct.lifetime_value DESC) AS quartile
FROM customer_totals ct
JOIN users u ON u.id = ct.user_id
ORDER BY ct.lifetime_value DESC;▶ Run this query in the playgroundCovers: RANK, PARTITION BY, CTE
A window function cannot be filtered in the same SELECT that computes it, so the ranking goes in a CTE and the filter outside.
WITH product_revenue AS (
SELECT p.category,
p.name,
ROUND(SUM(oi.subtotal), 2) AS revenue
FROM products p
JOIN order_items oi ON oi.product_id = p.id
GROUP BY p.id, p.category, p.name
),
ranked AS (
SELECT category, name, revenue,
RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rank_in_category
FROM product_revenue
)
SELECT category, name, revenue, rank_in_category
FROM ranked
WHERE rank_in_category <= 3
ORDER BY category, rank_in_category;▶ Run this query in the playgroundCovers: LAG
LAG reads the previous row of the ordered result. The first month has nothing before it, so its change is NULL.
WITH monthly AS (
SELECT strftime('%Y-%m', order_date) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status NOT IN ('cancelled', 'refunded')
GROUP BY month
)
SELECT month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
ROUND(revenue - LAG(revenue) OVER (ORDER BY month), 2) AS change
FROM monthly
ORDER BY month;▶ Run this query in the playgroundCovers: correlated subquery
The inner query refers to the outer row (e.department_id), so the average is worked out for that employee’s own department.
SELECT e.first_name, e.last_name, e.department, e.salary
FROM employees e
WHERE e.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.department_id = e.department_id
)
ORDER BY e.department, e.salary DESC;▶ Run this query in the playgroundCovers: window frame (ROWS BETWEEN)
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW is a three-row window: this order and the two before it. It counts rows, not calendar days, so it is a three-order average and not a three-day or three-month one.
SELECT o.user_id,
o.order_date,
o.total,
ROUND(AVG(o.total) OVER (
PARTITION BY o.user_id
ORDER BY o.order_date, o.id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS avg_last_3_orders
FROM orders o
ORDER BY o.user_id, o.order_date, o.id;▶ Run this query in the playgroundCovers: four-table JOIN
Follow the foreign keys: users to orders, orders to order_items, order_items to products.
SELECT u.city,
p.category,
COUNT(DISTINCT o.id) AS orders,
ROUND(SUM(oi.subtotal), 2) AS revenue
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.status = 'delivered'
GROUP BY u.city, p.category
ORDER BY revenue DESC
LIMIT 20;▶ Run this query in the playgroundIf you want to drill one topic at a time, pair a lesson with the playground: read the lesson, then write three or four queries of your own on the same clause before moving on. A sensible order is:
The full list of free lessons, in order, is on the Learn SQL page.
Once the individual clauses feel familiar, practice putting them together under a problem statement, the way a coding test or a real request from a colleague is phrased. The playground is the scratchpad for all of these:
SQL interviews for analyst roles vary from company to company, but the same skills come up again and again. It is more useful to be fluent in these than to memorise answers to specific questions:
For a longer set organised by topic and level, see the SQL interview questions page. The four queries below are common interview patterns you can run here.
Covers: DENSE_RANK
DENSE_RANK gives tied salaries the same rank and leaves no gaps, so rank 2 is always the second-highest distinct salary.
WITH ranked AS (
SELECT department, first_name, last_name, salary,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS salary_rank
FROM employees
)
SELECT department, first_name, last_name, salary
FROM ranked
WHERE salary_rank = 2
ORDER BY department;▶ Run this query in the playgroundCovers: GROUP BY, HAVING COUNT(*) > 1
Group by the columns that should be unique together and keep the groups with more than one row. Here: customers who reviewed the same product more than once.
SELECT user_id, product_id, COUNT(*) AS reviews
FROM reviews
GROUP BY user_id, product_id
HAVING COUNT(*) > 1
ORDER BY reviews DESC, user_id;▶ Run this query in the playgroundCovers: IS NULL, COUNT(column)
COUNT(*) counts rows; COUNT(shipped_date) skips the NULLs. The difference is the number of orders with no ship date.
SELECT status,
COUNT(*) AS orders,
COUNT(shipped_date) AS with_ship_date,
COUNT(*) - COUNT(shipped_date) AS missing_ship_date
FROM orders
GROUP BY status
ORDER BY orders DESC;▶ Run this query in the playgroundCovers: self join, COALESCE
The employees table joins to itself through manager_id. LEFT JOIN keeps the people who have no manager.
SELECT e.first_name || ' ' || e.last_name AS employee,
e.title,
COALESCE(m.first_name || ' ' || m.last_name, 'No manager') AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id
ORDER BY manager, employee;▶ Run this query in the playgroundSQLab Hub is a free SQL practice platform for students, aspiring data analysts, data engineers, developers, and anyone learning SQL through hands-on practice. The playground is one part of it; the lessons, challenges and projects all use the same kind of data, so what you learn in one carries over to the others.
A SQL editor with a schema browser, real query execution and a results table. Runs in the browser with nothing to install.
8 linked tables covering an online store and a company’s staff, so joins and aggregates have something meaningful to work on.
A library of problems at easy, medium and hard level, filterable by topic.
Interview questions grouped by topic, plus a timed skill test.
Free lessons from SELECT through joins, subqueries, CTEs and window functions.
Run queries without an account. Sign in to save queries and track your progress.
SQLite supports many of the core SQL concepts used across relational database systems, including SELECT, JOIN, GROUP BY, HAVING, subqueries, CTEs, and window functions. However, database systems differ in areas such as date functions, string functions, JSON features, procedural SQL, and vendor-specific syntax. The playground executes SQLite only, so use it to practice the concepts and check dialect-specific syntax against the documentation for your own database.
Type a query into the editor at the top of this page and press Ctrl+Enter, or use the Run Query button. It runs on a real SQLite database with 8 tables and 1,476 rows, and the result appears under the editor. There is nothing to install and you do not need an account to run queries.
If you would rather start from a problem than a blank editor, use the practice questions on this page, the sample queries in the bar above the editor, or the SQL challenges page.
No. Writing and running queries works without an account. A free account adds a few things: saving queries so you can come back to them, XP and a place on the leaderboard, and the timed SQL skill test.
SQLite. It covers the SQL most people need to practice: SELECT, WHERE, JOIN, GROUP BY, HAVING, subqueries, common table expressions (WITH) and window functions such as ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD and NTILE.
Two SQLite habits are worth knowing here. Dates are stored as YYYY-MM-DD text and handled with strftime() and date(), and strings are joined with the || operator.
The playground runs SQLite only. It does not execute MySQL, PostgreSQL or SQL Server.
SQLite supports many of the core SQL concepts used across relational database systems, including SELECT, JOIN, GROUP BY, HAVING, subqueries, CTEs, and window functions. However, database systems differ in areas such as date functions, string functions, JSON features, procedural SQL, and vendor-specific syntax. Practice the concepts here, then check the syntax of anything dialect-specific in the documentation for the database you will use.
There are 8 tables: six describe an online store and two describe the company’s staff.
The Schema panel next to the editor lists every column. Click a table to expand it, and click a column to insert it into your query.
The database is read-only, so a query has to start with SELECT or WITH. INSERT, UPDATE, DELETE and CREATE are rejected, which also means nobody else can change the data you are practicing on.
A query can run for up to 2 seconds and results are cut off at 500 rows. CROSS JOIN, WITH RECURSIVE and queries with more than five joins are not accepted.
WHERE filters individual rows before they are grouped. HAVING filters the groups after GROUP BY has done its work. If the condition uses an aggregate such as COUNT(), SUM() or AVG(), it belongs in HAVING; if it tests a plain column, it belongs in WHERE. One query can use both.
SELECT city, COUNT(*) AS active_customers
FROM users
WHERE status = 'active' -- filters rows before grouping
GROUP BY city
HAVING COUNT(*) >= 5 -- filters groups after counting
ORDER BY active_customers DESC;Practice writing queries from a blank editor rather than reading solutions. Work through the topics interviewers return to most: joins, GROUP BY with HAVING, subqueries, CTEs, window functions, NULL handling and finding duplicates. Say out loud what each clause does as you write it, because interviews usually ask you to explain your reasoning.
Every company sets its own questions, so treat any list as practice for the underlying skills and not as a prediction of what you will be asked.
Yes. The editor opens with a working query that you can run straight away and then change: alter the LIMIT, sort by a different column, or remove the JOIN and see what happens. The beginner questions on this page use one table at a time, and the free lessons explain each clause before you try it.
Yes. On a small screen the schema, the editor and the output become three tabs, and the Run button sits above the editor.