Skip to main content

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 = NULL is not true in SQL — always use IS NULL or IS NOT NULL
  • ⚠️ String comparisons are case-sensitive in PostgreSQL by default
  • ⚠️ ORDER BY without LIMIT on 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:

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 WHERE and comparison operators
  • How to combine conditions with AND, OR, and NOT
  • How to sort results with ORDER BY
  • How to limit and paginate results with LIMIT and OFFSET
  • How NULL works and why it behaves unexpectedly
  • How to search text with LIKE and ILIKE

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 NULL and 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:

namepricecategory
Wireless Keyboard89.99Electronics
Mechanical Mouse64.99Electronics
Laptop Stand49.99Accessories
USB-C Hub39.99Electronics
Desk Pad29.99Accessories
Monitor Light59.99Electronics
Cable Management19.99Accessories
Webcam 4K129.99Electronics

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;
nameprice
Wireless Keyboard89.99
Mechanical Mouse64.99
Monitor Light59.99
Webcam 4K129.99

The full set of comparison operators:

OperatorMeaningExample
=Equal toWHERE category = 'Electronics'
<> or !=Not equal toWHERE status <> 'cancelled'
>Greater thanWHERE price > 50
<Less thanWHERE price < 50
>=Greater than or equal toWHERE stock_quantity >= 1
<=Less than or equal toWHERE 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;
namepricestock_quantity
Wireless Keyboard89.9945
Mechanical Mouse64.9930
USB-C Hub39.99120
Monitor Light59.9918
Webcam 4K129.998

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;
nameprice
Desk Pad29.99
Cable Management19.99
Webcam 4K129.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:

ExpressionResult
TRUE AND NULLNULL
FALSE AND NULLFALSE
TRUE OR NULLTRUE
FALSE OR NULLNULL

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%';
nameprice
USB-C Hub39.99
Webcam 4K129.99

The % character is a wildcard meaning "zero or more of any character". Placement matters:

PatternMeaning
'%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;
nameprice
Cable Management19.99
Desk Pad29.99
USB-C Hub39.99
Laptop Stand49.99
Monitor Light59.99
Mechanical Mouse64.99
Wireless Keyboard89.99
Webcam 4K129.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;
namecategoryprice
Laptop StandAccessories49.99
Desk PadAccessories29.99
Cable ManagementAccessories19.99
Webcam 4KElectronics129.99
Wireless KeyboardElectronics89.99
Mechanical MouseElectronics64.99
Monitor LightElectronics59.99
USB-C HubElectronics39.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;
nameprice
Webcam 4K129.99
Wireless Keyboard89.99
Mechanical Mouse64.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:

  1. Checking for NULL with = instead of IS NULL (most common)
  2. Case mismatch in string comparison
  3. 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:

  1. Joining tables without a proper condition (produces a cartesian product) — we'll cover this in a future SQL Joins article
  2. OR condition 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

  1. What's wrong with this query?

    SELECT * FROM users WHERE email = NULL;
    Show Answer

    NULL cannot be compared with =. This query will always return 0 rows, silently. The correct query is:

    SELECT * FROM users WHERE email IS NULL;
  2. Which products will this query return?

    SELECT name FROM products
    WHERE category = 'Electronics' AND price < 50
    OR category = 'Accessories';
    Show Answer

    Because AND has higher precedence than OR, 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
  3. How would you find all ShopFlow orders that are neither pending nor shipped?

    Show Answer
    SELECT id, status FROM orders
    WHERE status NOT IN ('pending', 'shipped');

    This returns only the cancelled order (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:

nameprice
Webcam 4K129.99
Wireless Keyboard89.99
Mechanical Mouse64.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 rows
  • AND stock_quantity > 0 further narrows to in-stock items only
  • ORDER BY price DESC sorts highest price first
  • LIMIT 3 returns only the top 3 results

Summary: Key Takeaways

  • SQL is declarative — describe what you want, not how to get it
  • SELECT specifies columns; FROM specifies the table; always name specific columns instead of SELECT * in production
  • WHERE filters rows using comparison operators (=, <>, >, <, >=, <=)
  • Combine conditions with AND and OR; use parentheses when mixing them to avoid precedence surprises
  • NULL means "no value" — always check with IS NULL / IS NOT NULL, never with =
  • LIKE matches text patterns with % (any characters) and _ (one character); use ILIKE for case-insensitive matching in PostgreSQL
  • ORDER BY sorts results; always pair it with LIMIT for pagination
  • LIMIT caps result count; OFFSET skips 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.