SQL Basics: Reading Data with SELECT
Every database interaction starts with a question. "Which products are in stock?" "What orders did this user place?" "How many customers signed up this week?" The tool for asking those questions is the SELECT statement — the most important command in SQL, and the one you'll write hundreds of times before you write anything else.
Let's discover how it works.
Quick Reference
Basic syntax:
SELECT column1, column2
FROM table_name
WHERE condition
ORDER BY column1 DESC
LIMIT 10;
Common patterns:
- Select all columns:
SELECT * FROM products - Filter rows:
WHERE price > 50 - Sort results:
ORDER BY created_at DESC - Limit results:
LIMIT 20 OFFSET 40 - Check for NULL:
WHERE email IS NULL(not= NULL)
Gotchas:
- ⚠️
NULL = NULLis not true in SQL — always useIS NULLorIS NOT NULL - ⚠️ String comparisons are case-sensitive in PostgreSQL by default
- ⚠️
ORDER BYwithoutLIMITon a large table is expensive
Version Information
Tested with:
- PostgreSQL: 15.x, 16.x
- Node.js: v18.x, v20.x, v22.x
Last verified: May 2025
What You Need to Know First
Required reading:
- Relational Databases: How Structured Data Works — You need to understand tables, rows, columns, and the ShopFlow schema before writing queries against them
Tools you'll need:
- PostgreSQL installed locally, or a free cloud instance (Supabase and Neon both have generous free tiers)
psql(the PostgreSQL command-line client) or a GUI like TablePlus or DBeaver
What We'll Cover in This Article
By the end of this guide, you'll understand:
- How to select specific columns from a table
- How to filter rows with
WHEREand comparison operators - How to combine conditions with
AND,OR, andNOT - How to sort results with
ORDER BY - How to limit and paginate results with
LIMITandOFFSET - How
NULLworks and why it behaves unexpectedly - How to search text with
LIKEandILIKE
What We'll Explain Along the Way
We'll introduce these concepts with full explanations:
- Declarative vs. procedural thinking
- SQL keywords and case conventions
- The difference between
NULLand an empty string - Case sensitivity in string comparisons
Setting Up: The ShopFlow Tables
Before we write any queries, let's populate the database with ShopFlow data. Run this SQL to create and seed the tables we'll query throughout this article. Don't worry about understanding every line — we'll cover CREATE TABLE in depth in a future Schema Design: Tables, Types, and Constraints article.
-- Create the tables
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price NUMERIC(10, 2) NOT NULL,
stock_quantity INTEGER NOT NULL DEFAULT 0,
category TEXT NOT NULL
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL,
price_at_purchase NUMERIC(10, 2) NOT NULL
);
-- Seed with sample data
INSERT INTO users (name, email, created_at) VALUES
('Aisha Mwangi', 'aisha@example.com', '2024-01-15 09:23:00'),
('Marco Rossi', 'marco@example.com', '2024-01-16 14:05:00'),
('Priya Sharma', 'priya@example.com', '2024-01-17 11:30:00'),
('Carlos Rivera', 'carlos@example.com', '2024-02-01 08:00:00'),
('Lin Wei', 'lin@example.com', '2024-02-14 16:45:00');
INSERT INTO products (name, description, price, stock_quantity, category) VALUES
('Wireless Keyboard', 'Compact 75% layout, USB-C charging', 89.99, 45, 'Electronics'),
('Mechanical Mouse', 'Silent clicks, 16000 DPI sensor', 64.99, 30, 'Electronics'),
('Laptop Stand', 'Adjustable aluminium stand', 49.99, 0, 'Accessories'),
('USB-C Hub', '7-in-1 hub with 4K HDMI', 39.99, 120, 'Electronics'),
('Desk Pad', 'Large 90x40cm leather-feel mat', 29.99, 75, 'Accessories'),
('Monitor Light', 'USB-powered screen bar, no glare', 59.99, 18, 'Electronics'),
('Cable Management', 'Under-desk cable tray kit', 19.99, 0, 'Accessories'),
('Webcam 4K', '4K 30fps, built-in ring light', 129.99, 8, 'Electronics');
INSERT INTO orders (user_id, status, created_at) VALUES
(1, 'shipped', '2024-02-10 10:00:00'),
(1, 'pending', '2024-03-05 14:30:00'),
(2, 'shipped', '2024-02-18 09:15:00'),
(3, 'cancelled', '2024-02-20 11:00:00'),
(3, 'shipped', '2024-03-01 16:00:00'),
(5, 'pending', '2024-03-10 08:30:00');
INSERT INTO order_items (order_id, product_id, quantity, price_at_purchase) VALUES
(1, 1, 1, 89.99),
(1, 4, 2, 39.99),
(2, 6, 1, 59.99),
(3, 2, 1, 64.99),
(3, 5, 1, 29.99),
(4, 8, 1, 129.99),
(5, 1, 1, 89.99),
(5, 3, 1, 49.99),
(6, 4, 3, 39.99);
Run this in your PostgreSQL client. You now have the full ShopFlow dataset to work with.
Part 1: The Mental Shift — Thinking Declaratively
Before writing a single line of SQL, there's a thinking shift that will make everything click faster.
When you write JavaScript or TypeScript, you think procedurally — you describe the steps:
// Procedural: describe HOW to get the result
const expensiveProducts = [];
for (const product of products) {
if (product.price > 50) {
expensiveProducts.push(product);
}
}
SQL is declarative — you describe what you want, not how to get it:
-- Declarative: describe WHAT you want
SELECT * FROM products WHERE price > 50;
The database engine decides how to retrieve that result efficiently. It might scan every row, use an index, or apply some optimization you didn't think of. Your job is to express the intent clearly. The database handles the execution.
This takes some adjustment, but it becomes natural quickly. Let's build it up step by step.
Part 2: SELECT and FROM — Choosing Your Data
The simplest possible SQL query has two parts: what columns to return, and which table to look in.
Selecting All Columns
The * wildcard means "give me every column":
-- Return every column from every row in the products table
SELECT * FROM products;
Running this against the ShopFlow data returns all 8 products with all 6 columns. It's useful for exploration but not for production queries — you're asking the database to send you data you might not need.
Selecting Specific Columns
Name the columns you actually want, separated by commas:
-- Return only the name, price, and category columns
SELECT name, price, category
FROM products;
Result:
| name | price | category |
|---|---|---|
| Wireless Keyboard | 89.99 | Electronics |
| Mechanical Mouse | 64.99 | Electronics |
| Laptop Stand | 49.99 | Accessories |
| USB-C Hub | 39.99 | Electronics |
| Desk Pad | 29.99 | Accessories |
| Monitor Light | 59.99 | Electronics |
| Cable Management | 19.99 | Accessories |
| Webcam 4K | 129.99 | Electronics |
Notice the column order in the result matches the order you wrote them — not the order they're stored in the table. You're in control.
Renaming Columns with AS
Sometimes a column name isn't ideal for display. Use AS to give it an alias:
-- Rename columns in the result set
SELECT
name AS product_name,
price AS price_usd,
stock_quantity AS units_available
FROM products;
The alias only affects the result — the actual column names in the table don't change.
A Note on SQL Formatting
SQL is case-insensitive for keywords — SELECT, select, and Select all work. The convention in this module (and most professional environments) is:
- UPPERCASE for SQL keywords (
SELECT,FROM,WHERE) - lowercase for table and column names
Whitespace and newlines don't matter either. These two queries are identical:
SELECT name, price FROM products WHERE category = 'Electronics';
SELECT
name,
price
FROM products
WHERE category = 'Electronics';
Use the multiline format for anything longer than one line — it's much easier to read and debug.
Part 3: WHERE — Filtering Rows
WHERE is where SQL becomes powerful. It filters which rows are returned based on one or more conditions.
Basic Comparison Operators
-- Products that cost more than $50
SELECT name, price FROM products
WHERE price > 50;
| name | price |
|---|---|
| Wireless Keyboard | 89.99 |
| Mechanical Mouse | 64.99 |
| Monitor Light | 59.99 |
| Webcam 4K | 129.99 |
The full set of comparison operators:
| Operator | Meaning | Example |
|---|---|---|
= | Equal to | WHERE category = 'Electronics' |
<> or != | Not equal to | WHERE status <> 'cancelled' |
> | Greater than | WHERE price > 50 |
< | Less than | WHERE price < 50 |
>= | Greater than or equal to | WHERE stock_quantity >= 1 |
<= | Less than or equal to | WHERE price <= 39.99 |
Filtering Text
String values go in single quotes (not double quotes — double quotes mean column/table names in PostgreSQL):
-- All products in the Electronics category
SELECT name, price FROM products
WHERE category = 'Electronics';
Combining Conditions with AND and OR
Use AND when both conditions must be true:
-- Electronics products that are in stock
SELECT name, price, stock_quantity FROM products
WHERE category = 'Electronics'
AND stock_quantity > 0;
| name | price | stock_quantity |
|---|---|---|
| Wireless Keyboard | 89.99 | 45 |
| Mechanical Mouse | 64.99 | 30 |
| USB-C Hub | 39.99 | 120 |
| Monitor Light | 59.99 | 18 |
| Webcam 4K | 129.99 | 8 |
The Laptop Stand (Electronics, out of stock) doesn't appear — it fails the stock_quantity > 0 condition.
Use OR when either condition can be true:
-- Products that are either very cheap or very expensive
SELECT name, price FROM products
WHERE price < 25
OR price > 100;
| name | price |
|---|---|
| Desk Pad | 29.99 |
| Cable Management | 19.99 |
| Webcam 4K | 129.99 |
Operator Precedence: AND Before OR
Here's where things get tricky. AND is evaluated before OR — just like multiplication before addition in math. This catches beginners often:
-- ❌ Wrong: meant to find cheap Electronics OR cheap Accessories
-- Actually finds: (cheap Electronics) OR (all Accessories)
SELECT name, price, category FROM products
WHERE category = 'Electronics'
AND price < 50
OR category = 'Accessories';
-- ✅ Correct: parentheses enforce the intended grouping
SELECT name, price, category FROM products
WHERE (category = 'Electronics' AND price < 50)
OR (category = 'Accessories' AND price < 50);
Rule of thumb: When mixing AND and OR, always use parentheses to make your intent explicit. Don't rely on precedence rules that you or a future reader might misremember.
NOT: Excluding Conditions
NOT inverts a condition:
-- All products that are NOT in the Electronics category
SELECT name, category FROM products
WHERE NOT category = 'Electronics';
-- More common way to write the same thing
SELECT name, category FROM products
WHERE category <> 'Electronics';
IN: Matching a List of Values
Instead of chaining multiple OR conditions, use IN for cleaner syntax:
-- ❌ Verbose: multiple OR conditions
SELECT name FROM orders
WHERE status = 'pending'
OR status = 'shipped';
-- ✅ Clean: IN with a list
SELECT name FROM products
WHERE category IN ('Electronics', 'Accessories');
NOT IN excludes the list:
-- Orders that aren't cancelled or pending
SELECT id, status FROM orders
WHERE status NOT IN ('cancelled', 'pending');
BETWEEN: Range Conditions
-- Products priced between $40 and $80 (inclusive)
SELECT name, price FROM products
WHERE price BETWEEN 40 AND 80;
BETWEEN is inclusive on both ends — it's equivalent to price >= 40 AND price <= 80.
Part 4: NULL — The Value That Isn't a Value
NULL is one of the most misunderstood concepts in SQL. It doesn't mean zero, it doesn't mean an empty string, and it doesn't behave like a normal value. NULL means the absence of a value — unknown, not applicable, or not provided.
Here's the critical rule: you cannot use = to check for NULL.
-- ❌ This will never return any rows, even if description IS null
SELECT name FROM products
WHERE description = NULL;
-- ✅ Always use IS NULL
SELECT name FROM products
WHERE description IS NULL;
-- ✅ And IS NOT NULL for the opposite
SELECT name FROM products
WHERE description IS NOT NULL;
Why does = NULL fail? Because in SQL's logic, NULL = NULL evaluates to NULL (unknown), not TRUE. The database doesn't know what the missing value is, so it can't confirm equality. It's counterintuitive, but it's consistent with the underlying logic.
NULL in AND/OR conditions:
| Expression | Result |
|---|---|
TRUE AND NULL | NULL |
FALSE AND NULL | FALSE |
TRUE OR NULL | TRUE |
FALSE OR NULL | NULL |
Rows where the WHERE condition evaluates to NULL are excluded from results — only TRUE rows are returned.
In the ShopFlow data, the description column has values for all products. But if any were NULL, you'd use IS NULL to find them.
Part 5: LIKE — Searching Text Patterns
When you need to find rows where a text column contains something rather than equals something, use LIKE:
-- Products whose name contains "4K"
SELECT name, price FROM products
WHERE name LIKE '%4K%';
| name | price |
|---|---|
| USB-C Hub | 39.99 |
| Webcam 4K | 129.99 |
The % character is a wildcard meaning "zero or more of any character". Placement matters:
| Pattern | Meaning |
|---|---|
'%4K%' | Contains "4K" anywhere |
'4K%' | Starts with "4K" |
'%4K' | Ends with "4K" |
'_SB%' | Second and third chars are "SB" (_ = one character) |
LIKE is Case-Sensitive in PostgreSQL
This trips up many developers:
-- ❌ Won't find "Webcam 4K" — LIKE is case-sensitive
SELECT name FROM products
WHERE name LIKE '%webcam%';
-- ✅ Use ILIKE for case-insensitive matching (PostgreSQL-specific)
SELECT name FROM products
WHERE name ILIKE '%webcam%';
ILIKE is a PostgreSQL extension — it doesn't exist in all databases. MySQL's LIKE is case-insensitive by default, which is another behavioral difference to be aware of.
Part 6: ORDER BY — Sorting Results
Without ORDER BY, the database returns rows in whatever order it finds them — this is effectively random from your application's perspective. Always sort if order matters.
-- Products sorted by price, cheapest first
SELECT name, price FROM products
ORDER BY price ASC;
| name | price |
|---|---|
| Cable Management | 19.99 |
| Desk Pad | 29.99 |
| USB-C Hub | 39.99 |
| Laptop Stand | 49.99 |
| Monitor Light | 59.99 |
| Mechanical Mouse | 64.99 |
| Wireless Keyboard | 89.99 |
| Webcam 4K | 129.99 |
ASC (ascending) is the default — you can omit it. Use DESC for descending:
-- Most expensive first
SELECT name, price FROM products
ORDER BY price DESC;
Sorting by Multiple Columns
Separate columns with commas. The database sorts by the first column, then breaks ties with the second:
-- Sort by category alphabetically, then by price within each category
SELECT name, category, price FROM products
ORDER BY category ASC, price DESC;
| name | category | price |
|---|---|---|
| Laptop Stand | Accessories | 49.99 |
| Desk Pad | Accessories | 29.99 |
| Cable Management | Accessories | 19.99 |
| Webcam 4K | Electronics | 129.99 |
| Wireless Keyboard | Electronics | 89.99 |
| Mechanical Mouse | Electronics | 64.99 |
| Monitor Light | Electronics | 59.99 |
| USB-C Hub | Electronics | 39.99 |
NULL Values in ORDER BY
NULL values sort to the end with ASC and to the beginning with DESC in PostgreSQL. You can control this explicitly:
-- Put NULLs last regardless of sort direction
SELECT name, description FROM products
ORDER BY description DESC NULLS LAST;
Part 7: LIMIT and OFFSET — Pagination
Returning every row in a table is almost never what you want in production. Use LIMIT to cap the number of rows:
-- Return only the 3 most expensive products
SELECT name, price FROM products
ORDER BY price DESC
LIMIT 3;
| name | price |
|---|---|
| Webcam 4K | 129.99 |
| Wireless Keyboard | 89.99 |
| Mechanical Mouse | 64.99 |
OFFSET skips a number of rows before returning results. Together, LIMIT and OFFSET implement pagination:
-- Page 1: rows 1-3
SELECT name, price FROM products
ORDER BY price DESC
LIMIT 3 OFFSET 0;
-- Page 2: rows 4-6
SELECT name, price FROM products
ORDER BY price DESC
LIMIT 3 OFFSET 3;
-- Page 3: rows 7-8
SELECT name, price FROM products
ORDER BY price DESC
LIMIT 3 OFFSET 6;
The formula is: OFFSET = (page_number - 1) * page_size.
Important: Always use ORDER BY with LIMIT. Without it, the database might return different rows on different executions — pagination becomes meaningless.
Common Misconceptions
❌ Misconception: NULL = NULL is true
Reality: In SQL, NULL is not equal to anything — including itself. NULL = NULL evaluates to NULL, not TRUE.
Why this matters: Queries like WHERE email = NULL silently return no rows instead of raising an error. You'll be confused about why the filter isn't working.
Fix:
-- ❌ Always returns 0 rows
SELECT * FROM users WHERE email = NULL;
-- ✅ Correctly finds rows where email is missing
SELECT * FROM users WHERE email IS NULL;
❌ Misconception: SELECT * is fine for production queries
Reality: SELECT * retrieves every column, including ones your application doesn't need. This wastes network bandwidth, memory, and can break your application if a column is added or removed from the table.
Why this matters: At scale, unnecessary data transfer is a real performance cost. And if your application maps columns to specific struct positions, an added column can shift everything.
Fix:
-- ❌ Brittle and wasteful
SELECT * FROM products;
-- ✅ Explicit about what you need
SELECT id, name, price FROM products;
❌ Misconception: String comparison with = is case-insensitive
Reality: In PostgreSQL, 'Electronics' = 'electronics' is FALSE. String equality is case-sensitive by default.
Why this matters: WHERE category = 'electronics' will miss rows where category is stored as 'Electronics'.
Fix:
-- ❌ Misses rows if category is stored with different casing
WHERE category = 'electronics'
-- ✅ Option 1: Use ILIKE (PostgreSQL-specific, pattern matching)
WHERE category ILIKE 'electronics'
-- ✅ Option 2: Lowercase both sides
WHERE LOWER(category) = 'electronics'
-- ✅ Option 3 (best): Store data with consistent casing to begin with
Troubleshooting Common Issues
Problem: Query returns no rows when you expect results
Symptoms: Your SELECT runs without error but returns an empty result set.
Common Causes:
- Checking for
NULLwith=instead ofIS NULL(most common) - Case mismatch in string comparison
- Wrong table name — query ran against a different table
Diagnostic Steps:
-- Step 1: First, confirm there's actually data in the table
SELECT COUNT(*) FROM products;
-- Step 2: Remove your WHERE clause entirely to see all rows
SELECT * FROM products;
-- Step 3: Add conditions back one at a time to find which one
-- is filtering too aggressively
SELECT * FROM products WHERE category = 'Electronics';
SELECT * FROM products WHERE category = 'Electronics' AND stock_quantity > 0;
Problem: More rows than expected
Symptoms: Your query returns duplicates or far more rows than the table has.
Common Causes:
- Joining tables without a proper condition (produces a cartesian product) — we'll cover this in a future SQL Joins article
ORcondition that's broader than intended (check your parentheses)
Diagnostic Steps:
-- Count total rows vs what you expect
SELECT COUNT(*) FROM products;
-- Check if your WHERE condition is too broad
-- Start strict and loosen
WHERE price > 100
WHERE price > 50
WHERE price > 0 -- Should this really match everything?
Problem: ERROR: column "Name" does not exist
Symptoms: PostgreSQL throws a column-not-found error for a column you can clearly see in the table.
Cause: PostgreSQL folds unquoted identifiers to lowercase. If you quoted the column name with double quotes when creating it, you must quote it in every query. The fix is to never use double quotes for identifiers.
-- ❌ If the column was created as "Name" (double-quoted), this breaks
SELECT name FROM products;
-- ❌ This works but creates a maintenance problem
SELECT "Name" FROM products;
-- ✅ Best: always create and reference columns in lowercase
SELECT name FROM products;
Check Your Understanding
Quick Quiz
-
What's wrong with this query?
SELECT * FROM users WHERE email = NULL;Show Answer
NULLcannot be compared with=. This query will always return 0 rows, silently. The correct query is:SELECT * FROM users WHERE email IS NULL; -
Which products will this query return?
SELECT name FROM products
WHERE category = 'Electronics' AND price < 50
OR category = 'Accessories';Show Answer
Because
ANDhas higher precedence thanOR, this evaluates as:(category = 'Electronics' AND price < 50) OR (category = 'Accessories')It returns:
- USB-C Hub (Electronics, $39.99)
- Laptop Stand (Accessories, $49.99)
- Desk Pad (Accessories, $29.99)
- Cable Management (Accessories, $19.99)
If the intent was "cheap items in either category", the correct query uses parentheses:
WHERE (category = 'Electronics' OR category = 'Accessories') AND price < 50 -
How would you find all ShopFlow orders that are neither
pendingnorshipped?Show Answer
SELECT id, status FROM orders
WHERE status NOT IN ('pending', 'shipped');This returns only the
cancelledorder (id: 4).
Hands-On Challenge
Task: Write a single query that returns the name and price of all Electronics products that are currently in stock (stock_quantity > 0), sorted by price from highest to lowest, limited to the top 3.
Expected result:
| name | price |
|---|---|
| Webcam 4K | 129.99 |
| Wireless Keyboard | 89.99 |
| Mechanical Mouse | 64.99 |
Show Solution
SELECT name, price
FROM products
WHERE category = 'Electronics'
AND stock_quantity > 0
ORDER BY price DESC
LIMIT 3;
Why this works:
WHERE category = 'Electronics'filters to only Electronics rowsAND stock_quantity > 0further narrows to in-stock items onlyORDER BY price DESCsorts highest price firstLIMIT 3returns only the top 3 results
Summary: Key Takeaways
- SQL is declarative — describe what you want, not how to get it
SELECTspecifies columns;FROMspecifies the table; always name specific columns instead ofSELECT *in productionWHEREfilters rows using comparison operators (=,<>,>,<,>=,<=)- Combine conditions with
ANDandOR; use parentheses when mixing them to avoid precedence surprises NULLmeans "no value" — always check withIS NULL/IS NOT NULL, never with=LIKEmatches text patterns with%(any characters) and_(one character); useILIKEfor case-insensitive matching in PostgreSQLORDER BYsorts results; always pair it withLIMITfor paginationLIMITcaps result count;OFFSETskips rows — together they implement pagination
What's Next?
You can now read data from a single table with full control over filtering, sorting, and pagination. That covers a lot of ground — but most real questions involve summarizing data, not just listing it.
The natural next step is SQL Aggregations: Summarizing Data — where you'll learn to answer questions like "how many orders are in each status?" and "what's the average order value?" using COUNT, SUM, GROUP BY, and HAVING. Everything you just learned about WHERE and ORDER BY applies directly there too.