Your first SQL query¶
20-minute read plus typing. Assumes nothing about SQL. You need Python and a terminal.
Why this page exists¶
SQL is the one skill every data role uses every day. It is also small: four ideas get you through most questions people ask of a table. This page teaches those four on a real file, and shows the errors you will hit so they are familiar when they happen.
What SQL is¶
A table is rows and columns, like a spreadsheet tab with one rule: every column has one type (number, text, date) and every row is one thing. SQL (Structured Query Language, said "sequel" or "S-Q-L") is how you ask a database questions about tables. You describe the answer you want. The database works out how to get it.
Set up in two minutes¶
Make a folder, a Python virtual environment, and install DuckDB, a database that runs inside Python with no server. (Page 3 explains DuckDB and PostgreSQL properly.)
mkdir sql-practice && cd sql-practice
python3 -m venv .venv
source .venv/bin/activate
pip install duckdb
Now create the data. Save this as orders.csv in the same folder. It is ten orders from a small stationery shop.
order_id,order_date,customer,region,product,quantity,unit_price
1001,2026-01-05,Ava,East,Notebook,3,4.50
1002,2026-01-06,Ben,West,Pen,10,1.20
1003,2026-01-06,Cara,East,Backpack,1,39.00
1004,2026-01-09,Dev,North,Notebook,5,4.50
1005,2026-01-12,Ava,East,Pen,20,1.20
1006,2026-01-15,Eli,West,Backpack,2,39.00
1007,2026-01-18,Ben,West,Notebook,4,4.50
1008,2026-01-21,Fay,North,Pen,15,1.20
1009,2026-01-25,Cara,East,Notebook,2,4.50
1010,2026-01-30,Gus,West,Backpack,1,39.00
Save this as practice.py. It loads the CSV into a table called orders, then runs one query.
import duckdb
duckdb.sql("CREATE TABLE orders AS SELECT * FROM read_csv('orders.csv')")
duckdb.sql("SELECT * FROM orders").show()
Run it with python practice.py. From here on, you change the last line and run it again.
Query 1 - SELECT: pick columns¶
SELECT * FROM orders means "give me every column from the table orders". Output (real, run on DuckDB 2.0.0):
ββββββββββββ¬βββββββββββββ¬βββββββββββ¬ββββββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββββ
β order_id β order_date β customer β region β product β quantity β unit_price β
β int64 β date β varchar β varchar β varchar β int64 β double β
ββββββββββββΌβββββββββββββΌβββββββββββΌββββββββββΌβββββββββββΌβββββββββββΌβββββββββββββ€
β 1001 β 2026-01-05 β Ava β East β Notebook β 3 β 4.5 β
β 1002 β 2026-01-06 β Ben β West β Pen β 10 β 1.2 β
β 1003 β 2026-01-06 β Cara β East β Backpack β 1 β 39.0 β
β 1004 β 2026-01-09 β Dev β North β Notebook β 5 β 4.5 β
β 1005 β 2026-01-12 β Ava β East β Pen β 20 β 1.2 β
β 1006 β 2026-01-15 β Eli β West β Backpack β 2 β 39.0 β
β 1007 β 2026-01-18 β Ben β West β Notebook β 4 β 4.5 β
β 1008 β 2026-01-21 β Fay β North β Pen β 15 β 1.2 β
β 1009 β 2026-01-25 β Cara β East β Notebook β 2 β 4.5 β
β 1010 β 2026-01-30 β Gus β West β Backpack β 1 β 39.0 β
ββββββββββββ΄βββββββββββββ΄βββββββββββ΄ββββββββββ΄βββββββββββ΄βββββββββββ΄βββββββββββββ
10 rows 7 columns
Look at the second header row: DuckDB worked out the type of each column (int64, date, varchar which means text, double which means a decimal number). Types matter because you cannot add text.
Name the columns you want instead of *:
SELECT customer, product, quantity FROM orders
ββββββββββββ¬βββββββββββ¬βββββββββββ
β customer β product β quantity β
β varchar β varchar β int64 β
ββββββββββββΌβββββββββββΌβββββββββββ€
β Ava β Notebook β 3 β
β Ben β Pen β 10 β
β Cara β Backpack β 1 β
β Dev β Notebook β 5 β
β Ava β Pen β 20 β
β Eli β Backpack β 2 β
β Ben β Notebook β 4 β
β Fay β Pen β 15 β
β Cara β Notebook β 2 β
β Gus β Backpack β 1 β
ββββββββββββ΄βββββββββββ΄βββββββββββ
10 rows 3 columns
Query 2 - WHERE: keep some rows¶
WHERE is a filter. Only rows where the condition is true survive.
SELECT * FROM orders WHERE region = 'East'
ββββββββββββ¬βββββββββββββ¬βββββββββββ¬ββββββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββββ
β order_id β order_date β customer β region β product β quantity β unit_price β
β int64 β date β varchar β varchar β varchar β int64 β double β
ββββββββββββΌβββββββββββββΌβββββββββββΌββββββββββΌβββββββββββΌβββββββββββΌβββββββββββββ€
β 1001 β 2026-01-05 β Ava β East β Notebook β 3 β 4.5 β
β 1003 β 2026-01-06 β Cara β East β Backpack β 1 β 39.0 β
β 1005 β 2026-01-12 β Ava β East β Pen β 20 β 1.2 β
β 1009 β 2026-01-25 β Cara β East β Notebook β 2 β 4.5 β
ββββββββββββ΄βββββββββββββ΄βββββββββββ΄ββββββββββ΄βββββββββββ΄βββββββββββ΄βββββββββββββ
Text goes in single quotes ('East'). Join conditions with AND and OR:
SELECT * FROM orders WHERE region = 'West' AND quantity >= 2
ββββββββββββ¬βββββββββββββ¬βββββββββββ¬ββββββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββββ
β order_id β order_date β customer β region β product β quantity β unit_price β
β int64 β date β varchar β varchar β varchar β int64 β double β
ββββββββββββΌβββββββββββββΌβββββββββββΌββββββββββΌβββββββββββΌβββββββββββΌβββββββββββββ€
β 1002 β 2026-01-06 β Ben β West β Pen β 10 β 1.2 β
β 1006 β 2026-01-15 β Eli β West β Backpack β 2 β 39.0 β
β 1007 β 2026-01-18 β Ben β West β Notebook β 4 β 4.5 β
ββββββββββββ΄βββββββββββββ΄βββββββββββ΄ββββββββββ΄βββββββββββ΄βββββββββββ΄βββββββββββββ
A filter that matches nothing is not an error. It returns zero rows:
SELECT * FROM orders WHERE region = 'Mars'
ββββββββββββ¬βββββββββββββ¬βββββββββββ¬ββββββββββ¬ββββββββββ¬βββββββββββ¬βββββββββββββ
β order_id β order_date β customer β region β product β quantity β unit_price β
β int64 β date β varchar β varchar β varchar β int64 β double β
ββββββββββββ΄βββββββββββββ΄βββββββββββ΄ββββββββββ΄ββββββββββ΄βββββββββββ΄βββββββββββββ
0 rows
Zero rows usually means a typo in the value or different capitalisation, so check that before you trust an empty answer.
Query 3 - ORDER BY and LIMIT: sort and trim¶
Columns can be calculated. quantity * unit_price is the order total, and AS total names it. ORDER BY total DESC sorts largest first (ASC is the default). LIMIT 3 keeps three rows.
SELECT order_id, customer, quantity * unit_price AS total
FROM orders
ORDER BY total DESC
LIMIT 3
ββββββββββββ¬βββββββββββ¬βββββββββ
β order_id β customer β total β
β int64 β varchar β double β
ββββββββββββΌβββββββββββΌβββββββββ€
β 1006 β Eli β 78.0 β
β 1003 β Cara β 39.0 β
β 1010 β Gus β 39.0 β
ββββββββββββ΄βββββββββββ΄βββββββββ
Orders 1003 and 1010 tie at 39.0. SQL does not promise which of two tied rows comes first, so add a second sort column (ORDER BY total DESC, order_id) when you need a stable answer.
Query 4 - GROUP BY: one row per group¶
This is where SQL starts to feel useful. GROUP BY region puts rows into one bucket per region, and aggregate functions (count, sum, avg, min, max) summarize each bucket.
SELECT region,
count(*) AS orders,
sum(quantity * unit_price) AS revenue
FROM orders
GROUP BY region
ORDER BY revenue DESC
βββββββββββ¬βββββββββ¬ββββββββββ
β region β orders β revenue β
β varchar β int64 β double β
βββββββββββΌβββββββββΌββββββββββ€
β West β 4 β 147.0 β
β East β 4 β 85.5 β
β North β 2 β 40.5 β
βββββββββββ΄βββββββββ΄ββββββββββ
Ten rows became three. WHERE filters rows before grouping; HAVING filters groups after:
SELECT region, sum(quantity * unit_price) AS revenue
FROM orders
GROUP BY region
HAVING sum(quantity * unit_price) > 60
ORDER BY revenue DESC
βββββββββββ¬ββββββββββ
β region β revenue β
β varchar β double β
βββββββββββΌββββββββββ€
β West β 147.0 β
β East β 85.5 β
βββββββββββ΄ββββββββββ
Combine all four ideas. Average units per order by product, only for orders from 10 January on:
SELECT product, count(*) AS orders, avg(quantity) AS avg_qty
FROM orders
WHERE order_date >= '2026-01-10'
GROUP BY product
ORDER BY product
ββββββββββββ¬βββββββββ¬ββββββββββ
β product β orders β avg_qty β
β varchar β int64 β double β
ββββββββββββΌβββββββββΌββββββββββ€
β Backpack β 2 β 1.5 β
β Notebook β 2 β 3.0 β
β Pen β 2 β 17.5 β
ββββββββββββ΄βββββββββ΄ββββββββββ
The order you write it vs the order it runs¶
You write SELECT first, but the database reads the clauses in a different order. This explains most beginner confusion.
flowchart TB
A["FROM: pick the table"] --> B["WHERE: filter rows"]
B --> C["GROUP BY: make buckets"]
C --> D["HAVING: filter buckets"]
D --> E["SELECT: choose and calculate columns"]
E --> F["ORDER BY: sort"]
F --> G["LIMIT: trim"] The diagram shows the logical order the clauses run in. That is why WHERE cannot use a column alias you create in SELECT: SELECT has not run yet.
Three errors you will hit¶
1. A typo in a column name. Real output:
Binder Error: Referenced column "regoin" not found in FROM clause!
Candidate bindings: "region", "order_id", "product"
Read the second line. The database suggests the fix.
2. A column in SELECT that is not grouped. Running SELECT customer, region FROM orders GROUP BY customer gives:
Binder Error: column "region" must appear in the GROUP BY clause or must be part of an aggregate function.
Either add it to the GROUP BY list, or use ANY_VALUE("region") if the exact value of "region" is not important.
Each bucket has one customer but may hold several regions, so the database refuses to guess which one to show. Group by it, or aggregate it. PostgreSQL gives the same complaint in different words (page 3 shows it).
3. Double quotes for text. 'East' is text. "East" is a column name. Use single quotes for values.
Try it yourself¶
- Which customer spent the most in total? (
GROUP BY customer,sum,ORDER BY ... DESC LIMIT 1.) - How many units of each product were sold in the West?
- What is the average order total, over all orders? (An aggregate with no
GROUP BYreturns one row.)
Check yourself after writing them: question 1 is Eli at 78.0, then Cara at 48.0.
Go deeper¶
π PostgreSQL tutorial: Querying a Table - the same SELECT ideas from the PostgreSQL manual, with a weather table.
π DuckDB: GROUP BY - every form of GROUP BY DuckDB supports, including the shorthand GROUP BY ALL.
π PostgreSQL: SELECT - the full reference when you want every clause.
Next: From spreadsheets to SQL.