Querying with SELECT
SELECT is the whole of reading data in SQL. Everything else — joins, aggregates, windows — is a clause bolted onto this one statement.
All examples use the authors / books schema from the overview.
The shape of a SELECT
Section titled “The shape of a SELECT”SELECT title, price -- what to outputFROM books -- where rows come fromWHERE year > 1970 -- which rows to keepORDER BY price DESC -- how to sortLIMIT 10; -- how manyRemember the evaluation order: FROM → WHERE → SELECT → ORDER BY → LIMIT. SELECT runs late, which is why its aliases are usable in ORDER BY but not in WHERE.
Expressions and aliases
Section titled “Expressions and aliases”The select list is a list of expressions, not just column names. Arithmetic, function calls, string concatenation, and literals all work.
SELECT title, price, price * 0.9 AS sale_price, 'Book: ' || title AS label, ROUND(price * 1.2, 2) AS with_taxFROM books;|| is standard string concatenation and works in both engines. AS is optional (price * 0.9 sale_price is legal) but always write it — it reads better and avoids parsing ambiguities.
Quote an alias if it needs spaces or case. Double quotes are for identifiers, single quotes are for string literals:
SELECT title AS "Book Title", 'in stock' AS status FROM books;Escape a single quote by doubling it:
SELECT 'Le Guin''s novel'; -- => Le Guin's novelSELECT * returns every column. Fine at the CLI; avoid it in application code, where adding a column later silently changes your result shape.
SELECT b.* FROM books AS b; -- all columns from one table onlyWHERE filters rows one at a time. The expression must be a boolean; a row is kept only when it evaluates to true (not false, not NULL).
Comparison operators
Section titled “Comparison operators”SELECT * FROM books WHERE year = 1974;SELECT * FROM books WHERE year <> 1974; -- standard "not equal"SELECT * FROM books WHERE year != 1974; -- same, both engines accept itSELECT * FROM books WHERE price >= 12 AND price < 16;BETWEEN
Section titled “BETWEEN”SELECT * FROM books WHERE year BETWEEN 1970 AND 1980;BETWEEN is inclusive on both ends — equivalent to year >= 1970 AND year <= 1980.
SELECT * FROM books WHERE year IN (1968, 1974, 1979);SELECT * FROM books WHERE author_id IN (SELECT id FROM authors WHERE birth_year < 1950);SELECT * FROM books WHERE year NOT IN (1968, 1974);LIKE and ILIKE
Section titled “LIKE and ILIKE”LIKE does simple pattern matching. % matches any run of characters (including none), _ matches exactly one.
SELECT * FROM books WHERE title LIKE 'The %'; -- starts with "The "SELECT * FROM books WHERE title LIKE '%sea'; -- ends with "sea"SELECT * FROM books WHERE title LIKE '_izard%'; -- 2nd char onward is "izard"Case sensitivity is where the engines split:
| SQLite | PostgreSQL | |
|---|---|---|
LIKE |
Case-insensitive for ASCII by default | Case-sensitive |
ILIKE |
Not supported | Case-insensitive |
-- PostgreSQLSELECT * FROM books WHERE title ILIKE '%earthsea%';
-- Portable equivalent, works in bothSELECT * FROM books WHERE LOWER(title) LIKE '%earthsea%';Escape a literal % or _ with ESCAPE:
SELECT * FROM products WHERE code LIKE '100!%%' ESCAPE '!'; -- code starting "100%"PostgreSQL also has POSIX regular expressions via ~ (match), ~* (case-insensitive match), !~, !~*:
SELECT * FROM books WHERE title ~* '^(the|a) ';SQLite has a REGEXP operator in its grammar but no implementation built in — it errors unless the host application registers a regexp() function. Do not rely on it in portable SQL.
IS NULL
Section titled “IS NULL”= NULL never matches anything. Use IS NULL / IS NOT NULL:
SELECT * FROM books WHERE year IS NULL;SELECT * FROM books WHERE year IS NOT NULL;NULL and three-valued logic
Section titled “NULL and three-valued logic”SQL logic has three values: true, false, and unknown (NULL). Any comparison involving NULL yields NULL.
SELECT NULL = NULL; -- NULL, not trueSELECT NULL <> NULL; -- NULLSELECT 1 + NULL; -- NULLSELECT 'a' || NULL; -- NULL in PostgreSQL; NULL in SQLite tooThe truth tables:
AND |
true | false | null |
|---|---|---|---|
| true | true | false | null |
| false | false | false | false |
| null | null | false | null |
OR |
true | false | null |
|---|---|---|---|
| true | true | true | true |
| false | true | false | null |
| null | true | null | null |
Consequences worth internalising:
WHEREkeeps rows only where the result is true. Unknown is discarded, same as false.CHECKconstraints reject rows only where the result is false. Unknown is accepted. (Opposite ofWHERE.)NULL AND falseisfalse— the one case where a null still gives a definite answer.
Null-safe equality
Section titled “Null-safe equality”To compare treating two nulls as equal:
-- SQLite: the IS operator does exactly thisSELECT * FROM books WHERE year IS NULL;SELECT * FROM t WHERE a IS b; -- true when both are NULL
-- PostgreSQL (and SQLite 3.39+)SELECT * FROM t WHERE a IS NOT DISTINCT FROM b;SELECT * FROM t WHERE a IS DISTINCT FROM b; -- null-safe "not equal"COALESCE, NULLIF, and friends
Section titled “COALESCE, NULLIF, and friends”COALESCE(a, b, c, ...) returns the first non-null argument. It is standard and works everywhere.
SELECT title, COALESCE(year, 0) AS year FROM books;SELECT COALESCE(nickname, first_name, 'friend') AS greeting FROM users;NULLIF(a, b) returns NULL when a = b, otherwise a. The classic use is avoiding division by zero:
SELECT total / NULLIF(count, 0) AS average FROM stats; -- NULL instead of an error| Function | SQLite | PostgreSQL |
|---|---|---|
COALESCE(...) |
Yes | Yes |
NULLIF(a, b) |
Yes | Yes |
IFNULL(a, b) |
Yes | No — use COALESCE |
NULLs in ORDER BY
Section titled “NULLs in ORDER BY”-- PostgreSQL default: NULLS LAST for ASC, NULLS FIRST for DESCSELECT title, year FROM books ORDER BY year;
-- SQLite default: NULLs sort first in ASC, last in DESC
-- Explicit, works in PostgreSQL and SQLite 3.30+SELECT title, year FROM books ORDER BY year ASC NULLS LAST;ORDER BY
Section titled “ORDER BY”SELECT title, year, price FROM booksORDER BY year DESC, title ASC;Sort by multiple keys; ASC is the default. You can sort by an expression, a SELECT alias, or a 1-based output column position (legal but discouraged — it breaks when you edit the select list).
ORDER BY LENGTH(title); -- expressionORDER BY sale_price DESC; -- alias from SELECTORDER BY 2 DESC; -- second output columnText sorting uses a collation. SQLite’s default BINARY collation sorts by byte value, so uppercase sorts before lowercase; NOCASE gives ASCII-case-insensitive ordering. PostgreSQL uses the database’s locale collation, which usually sorts case-insensitively and ignores punctuation.
-- SQLiteSELECT title FROM books ORDER BY title COLLATE NOCASE;
-- PostgreSQLSELECT title FROM books ORDER BY title COLLATE "C"; -- byte orderSELECT title FROM books ORDER BY LOWER(title); -- portableLIMIT and OFFSET
Section titled “LIMIT and OFFSET”SELECT title FROM books ORDER BY year LIMIT 10;SELECT title FROM books ORDER BY year LIMIT 10 OFFSET 20; -- rows 21–30Both engines support LIMIT n OFFSET m. PostgreSQL also supports the standard FETCH FIRST n ROWS ONLY; SQLite does not.
DISTINCT
Section titled “DISTINCT”DISTINCT removes duplicate output rows, considering all selected columns together.
SELECT DISTINCT year FROM books;SELECT DISTINCT author_id, year FROM books; -- distinct pairsFor deduplication, DISTINCT treats NULLs as equal to each other — one of the few places it does.
PostgreSQL adds DISTINCT ON, which keeps the first row per group according to ORDER BY. It is the cleanest way to get “the latest row per key”:
-- PostgreSQL onlySELECT DISTINCT ON (author_id) author_id, title, yearFROM booksORDER BY author_id, year DESC;The ORDER BY must start with the DISTINCT ON expressions. The portable equivalent uses a window function — see aggregation and windows.
CASE is SQL’s conditional expression. It returns a value, so it can appear anywhere an expression can.
SELECT title, CASE WHEN price < 12 THEN 'cheap' WHEN price < 15 THEN 'normal' ELSE 'expensive' END AS bucketFROM books;Branches are tested in order; the first true one wins. Without ELSE, an unmatched row yields NULL.
The short “simple” form compares one expression against values:
SELECT CASE status WHEN 'p' THEN 'pending' WHEN 's' THEN 'shipped' ELSE 'unknown' ENDFROM orders;CASE inside an aggregate is how you pivot rows into columns:
SELECT author_id, COUNT(CASE WHEN year < 1980 THEN 1 END) AS pre_1980, COUNT(CASE WHEN year >= 1980 THEN 1 END) AS since_1980FROM booksGROUP BY author_id;Built-in functions
Section titled “Built-in functions”Function names are the least portable part of SQL. These are the ones you’ll reach for.
Strings
Section titled “Strings”| Task | SQLite | PostgreSQL |
|---|---|---|
| Length | LENGTH(s) |
LENGTH(s) / CHAR_LENGTH(s) |
| Case | UPPER(s), LOWER(s) |
UPPER(s), LOWER(s) |
| Trim | TRIM(s), LTRIM, RTRIM |
TRIM(s), LTRIM, RTRIM, BTRIM |
| Substring | SUBSTR(s, start, len) |
SUBSTR(s, start, len) or SUBSTRING(s FROM 2 FOR 3) |
| Replace | REPLACE(s, from, to) |
REPLACE(s, from, to) |
| Concatenate | s1 || s2 |
s1 || s2, CONCAT(s1, s2) |
| Find position | INSTR(haystack, needle) |
POSITION(needle IN haystack), STRPOS(haystack, needle) |
| Left/right slice | SUBSTR(s, 1, n) |
LEFT(s, n), RIGHT(s, n) |
| Split | not available | SPLIT_PART(s, delim, n) |
| Pad | PRINTF/FORMAT tricks |
LPAD(s, n, c), RPAD(s, n, c) |
| Regex replace | not available | REGEXP_REPLACE(s, pat, repl, 'g') |
| Format | FORMAT(fmt, ...) (3.38+, was PRINTF) |
FORMAT(fmt, ...) |
Both are 1-based for SUBSTR. CONCAT in PostgreSQL ignores nulls, while || propagates them — a useful difference:
-- PostgreSQLSELECT 'a' || NULL; -- NULLSELECT CONCAT('a', NULL); -- 'a'Numbers
Section titled “Numbers”| Task | SQLite | PostgreSQL |
|---|---|---|
| Absolute value | ABS(x) |
ABS(x) |
| Round | ROUND(x, n) |
ROUND(numeric, n) |
| Floor / ceiling | FLOOR, CEIL (3.35+) |
FLOOR, CEIL/CEILING |
| Modulo | x % y |
x % y, MOD(x, y) |
| Power | POWER(x, y) (3.35+) |
POWER(x, y), x ^ y |
| Random | RANDOM() (large signed int) |
RANDOM() (float in [0,1)) |
| Cast | CAST(x AS INTEGER) |
CAST(x AS integer) or x::integer |
Dates and times
Section titled “Dates and times”This is the widest gap between the engines, because SQLite has no date type.
SQLite stores dates as text/integer/real and provides five functions that interpret them: date(), time(), datetime(), julianday(), strftime() (plus unixepoch() in 3.38+). Each takes a time value followed by any number of modifiers.
-- SQLiteSELECT date('now'); -- 2026-08-09SELECT datetime('now', 'localtime'); -- local wall clockSELECT date('now', '+7 days'); -- a week outSELECT date('now', 'start of month'); -- 2026-08-01SELECT strftime('%Y-%m', '2026-08-09'); -- 2026-08SELECT julianday('2026-08-09') - julianday('2026-01-01'); -- 220.0 (days between)SELECT unixepoch('now'); -- seconds since 1970PostgreSQL has real temporal types and interval arithmetic:
-- PostgreSQLSELECT current_date; -- 2026-08-09SELECT now(); -- 2026-08-09 12:00:00+00SELECT now() + interval '7 days';SELECT date_trunc('month', now()); -- 2026-08-01 00:00:00+00SELECT to_char(now(), 'YYYY-MM'); -- 2026-08SELECT EXTRACT(year FROM now()); -- 2026SELECT age(now(), '2026-01-01'::timestamptz);SELECT now() AT TIME ZONE 'Europe/Paris'; -- convert to a wall clockSELECT (date '2026-08-09' - date '2026-01-01'); -- 220 (integer days)Key points
Section titled “Key points”SELECTevaluates afterWHERE, soSELECTaliases work inORDER BYbut notWHERE.- Single quotes are strings, double quotes are identifiers — never rely on SQLite’s double-quoted-string fallback.
NULLmakes comparisons unknown;WHEREkeeps only true rows,CHECKrejects only false ones.NOT INwith a null in the list matches nothing — reach forNOT EXISTS.LIKEis case-insensitive in SQLite and case-sensitive in PostgreSQL;ILIKEis PostgreSQL-only;LOWER(x) LIKE ...is portable.NULLS FIRST/NULLS LASTdefaults are opposite between the two engines — be explicit.LIMITneedsORDER BY; deepOFFSETis slow — use keyset pagination.- Date handling is the biggest portability gap: SQLite has functions over text/integers, PostgreSQL has real types and intervals.