Before learning what each SQL statement means it's worth learning how it is written. Just as in any language, there are spelling and grammar rules that, if you don't know them, will cost you hours staring at cryptic error messages. This lesson takes a SQL statement apart into its elementary pieces —keywords, identifiers, literals, operators and expressions— and explains the rules of the language: the semicolon, the treatment of upper and lower case, comments, professional style conventions, operator precedence and, very importantly, how to interpret a PostgreSQL error message so you can pinpoint the fault in seconds.
Here we'll use very simple statements as a vehicle. The goal is not yet to learn what SELECT or WHERE do —that's module 2—; the goal is to understand the spelling rules that any SQL statement is written with.
Contents
- Anatomy of a SQL statement
- The semicolon
- Upper and lower case
- Quoted identifiers
- Comments
- Style and formatting conventions
- Operators and their precedence
- Literals: strings, numbers, dates, booleans and NULL
- How to read a PostgreSQL error message
- Common Mistakes and Tips
- Exercises
- Conclusion
- Anatomy of a SQL statement
Every SQL statement is made up of a handful of element types. Let's look at one and label each piece:
| Element | In the example | What it is |
|---|---|---|
| Keywords | SELECT, FROM, WHERE, AS, AND, TRUE |
Reserved terms of the language |
| Identifiers | name, price, products, active, price_with_vat |
Names of tables, columns, aliases, schemas |
| Literals | 1.21, 10.00, TRUE |
Values written directly in the text |
| Operators | *, =, >, AND |
Symbols or words that combine values |
| Expressions | price * 1.21, active = TRUE AND price > 10.00 |
Combinations of the above that produce a value |
| Clauses | SELECT ..., FROM ..., WHERE ... |
Blocks that structure the statement |
| Terminator | ; |
Marks the end of the statement |
It's worth pausing on the difference between a keyword and an identifier, because it's the source of half of all beginner errors:
- A keyword is part of the language. You don't choose it:
SELECTalways means the same thing. - An identifier is a name somebody gave to an object: the
productstable is called that because whoever designed GreenStore decided so.
Rules for identifiers in PostgreSQL:
- They start with a letter or
_, and continue with letters, digits,_or$. - Maximum length: 63 characters (anything beyond that is silently truncated).
- They can't coincide with a reserved word... unless you quote them (section 4).
- Lowercase
snake_caseis recommended:order_lines,order_date,referred_by_id.
An expression is anything that evaluates to a value. You can check that without any table at all:
| ?column? |
|---|
| 5 |
In PostgreSQL, SELECT without FROM is perfectly valid and it's the best way to experiment with standalone expressions. We'll use it a lot in this lesson.
- The semicolon
The ; marks the end of a statement. Its role is twofold:
- To separate several statements in the same file or block.
- To tell the client it can now send the statement to the server.
Those are three independent statements. Without the semicolons, PostgreSQL would read SELECT 1 SELECT 2 SELECT 3 and report a syntax error.
If in psql you press Enter without having closed the statement, the prompt changes:
That hyphen (-#) means "still waiting". It isn't an error: you can spread a statement over as many lines as you like.
| Situation | Is ; needed? |
|---|---|
| A single statement in a graphical client | Optional, but advisable |
Several statements in a .sql file |
Mandatory |
psql metacommands (\dt, \l, \q) |
No, they aren't SQL |
| The last statement of a file | Advisable (avoids surprises when concatenating files) |
Tip: always put it in. It's free and it saves you from debugging why a script ran "halfway".
- Upper and lower case
There are two different rules here and it's best not to mix them up.
3.1. Keywords are case-insensitive
These four statements are identical as far as PostgreSQL is concerned:
SELECT name FROM products;
select name from products;
Select Name From Products;
sELeCt name FrOm products;Even so, the universal convention is to write keywords in UPPERCASE, because it makes the query far more readable: at a glance you can tell the structure of the language from the names in your schema.
3.2. Unquoted identifiers are folded to lowercase
This is the part that surprises people. The SQL standard says unquoted identifiers should be folded to uppercase; PostgreSQL, for historical reasons, folds them to lowercase. The practical effect is that:
is exactly the same as:
because PostgreSQL internally turns Name → name and Products → products.
Compare across engines:
| DBMS | Keywords | Unquoted identifiers | Table names |
|---|---|---|---|
| PostgreSQL | Insensitive | Folded to lowercase | Insensitive (because of the folding) |
| MySQL on Linux | Insensitive | Kept as written | Case-sensitive |
| MySQL on Windows/macOS | Insensitive | Kept as written | Insensitive (because of the file system) |
| SQLite | Insensitive | Kept as written | Insensitive |
| Oracle | Insensitive | Folded to UPPERCASE | Insensitive (because of the folding) |
The practical moral: always use lowercase and snake_case in your identifiers. That way your SQL behaves the same on every engine and you dodge the whole problem.
Careful: case-insensitivity affects the syntax, not the data. Values are case-sensitive:
| ?column? |
|---|
| false |
- Quoted identifiers
If you surround an identifier with double quotes, PostgreSQL takes it literally: it respects capitals, spaces and special characters.
-- These two tables would be DIFFERENT as far as PostgreSQL is concerned
CREATE TABLE products (...); -- internally called: products
CREATE TABLE "Products" (...); -- internally called: ProductsAnd from then on, any reference to the second one forces you to repeat the quotes:
SELECT * FROM "Products"; -- works
SELECT * FROM Products; -- ERROR: relation "products" does not existThat error is one of the most baffling: the table exists, you can see it in \dt, and yet the engine says it can't find it. The cause is always the same: somebody created it quoted with capitals.
| Use of double quotes | When it's legitimate |
|---|---|
An identifier that clashes with a reserved word ("order", "user") |
Acceptable, though renaming it is better |
An identifier with spaces ("customer name") |
Always avoid |
An identifier with capitals ("OrderDate") |
Always avoid |
An alias you want to look nice in a report (AS "Price with VAT") |
A reasonable use |
Course rule: every table and column in GreenStore is lowercase
snake_caseprecisely so that you never need double quotes. The only acceptable exception will be presentation aliases.
And don't forget: double quotes = identifier; single quotes = text string. They aren't interchangeable (we'll come back to this in section 8).
- Comments
SQL supports two forms of comment:
-- Single-line comment: from the two hyphens to the end of the line
/* Block comment:
it can span several lines
and it's handy for switching off chunks of a query */
SELECT name, -- the commercial name of the product
price /* retail selling price, VAT excluded */
FROM products;| Form | Syntax | Typical use |
|---|---|---|
| Line | -- text |
Annotating a column or a condition |
| Block | /* text */ |
Script headers, temporarily switching off several lines |
In PostgreSQL block comments can be nested, something not every engine allows:
Good practice with comments:
- Comment the why, not the what.
-- we filter the active onesis redundant if the line already saysWHERE active = TRUE. On the other hand-- inactive ones are discontinued products we keep for historical reasonsdoes add something. - Head long scripts with a block explaining what they do and who maintains them.
- Use
--to switch off a line while you debug, but clean up those leftovers before you call the query done.
- Style and formatting conventions
SQL ignores line breaks and extra spaces, so formatting is entirely for humans. And yes, it matters: a badly formatted twenty-line query is impossible to review.
Before (all on one line, keywords in lowercase, no structure):
select p.name, c.name, p.price from products p join categories c on p.category_id = c.id where p.active = true and p.price between 5 and 20 order by p.price desc;After (one clause per line, keywords in uppercase, clear alignment):
SELECT p.name,
c.name AS category,
p.price
FROM products AS p
JOIN categories AS c ON p.category_id = c.id
WHERE p.active = TRUE
AND p.price BETWEEN 5 AND 20
ORDER BY p.price DESC;Both do exactly the same thing. The second one can be read, reviewed and modified.
Conventions this course follows (and which are the most widespread in the industry):
| Rule | Example |
|---|---|
| Keywords in UPPERCASE | SELECT, FROM, WHERE, AND |
Identifiers in lowercase snake_case |
order_lines, order_date |
| One main clause per line | SELECT / FROM / WHERE / ORDER BY on their own lines |
Extra conditions indented under WHERE |
AND p.price > 10 |
| Long column lists, one per line | Makes changes easy to see in Git |
| Tables plural, columns singular | Table customers, column name |
Foreign keys as <singular_table>_id |
customer_id, product_id, category_id |
| No spaces or non-ASCII characters in identifiers | order_lines, not "Order Lines" |
Never SELECT * in production code |
Name the columns explicitly |
Notice that every identifier sticks to plain ASCII, lowercase letters and underscores. That's deliberate: anything else —capitals, spaces, accented characters— forces you into double quotes for the rest of the object's life, and quoted identifiers hurt portability between engines, are awkward to type on any keyboard and are a constant source of "relation does not exist" errors.
- Operators and their precedence
7.1. Arithmetic
| Operator | Meaning | Example | Result |
|---|---|---|---|
+ |
Addition | SELECT 10 + 3; |
13 |
- |
Subtraction | SELECT 10 - 3; |
7 |
* |
Multiplication | SELECT 10 * 3; |
30 |
/ |
Division | SELECT 10 / 3; |
3 |
% |
Modulo (remainder) | SELECT 10 % 3; |
1 |
^ |
Exponentiation | SELECT 2 ^ 10; |
1024 |
The trap is /: if both operands are integers, the division is integer division.
| integer_division | decimal_division |
|---|---|
| 3 | 3.3333333333333333 |
Just by writing 10.0 instead of 10 you change the type of the literal and with it the result. It's a classic source of mismatches when calculating percentages.
7.2. Comparison
| Operator | Meaning | Example |
|---|---|---|
= |
Equal | price = 10 |
<> or != |
Not equal | status <> 'cancelled' |
<, > |
Less than, greater than | stock > 0 |
<=, >= |
Less or equal, greater or equal | price <= 20 |
BETWEEN ... AND ... |
Within a range, bounds included | price BETWEEN 5 AND 20 |
IN (...) |
Belongs to a list | country IN ('Spain','France') |
IS NULL / IS NOT NULL |
Is (or isn't) null | employee_id IS NULL |
<> is the standard form of "not equal"; != works in PostgreSQL and in almost every engine, but <> is more portable. The BETWEEN, IN, LIKE and IS NULL operators are studied thoroughly in module 4.
7.3. Logical
| Operator | Returns true when... |
|---|---|
AND |
Both conditions are true |
OR |
At least one condition is true |
NOT |
The condition is false |
7.4. Precedence
When you mix operators, PostgreSQL evaluates them in this order (from highest to lowest priority):
| Level | Operators |
|---|---|
| 1 | () parentheses |
| 2 | :: type cast |
| 3 | - unary (negative sign) |
| 4 | ^ exponentiation |
| 5 | *, /, % |
| 6 | +, - |
| 7 | BETWEEN, IN, LIKE |
| 8 | =, <>, <, >, <=, >= |
| 9 | IS NULL, IS NOT NULL |
| 10 | NOT |
| 11 | AND |
| 12 | OR |
What causes the most trouble is that AND has higher priority than OR. Compare:
SELECT TRUE OR FALSE AND FALSE; -- evaluated as: TRUE OR (FALSE AND FALSE)
SELECT (TRUE OR FALSE) AND FALSE; -- we force a different order with parentheses| Query | Result |
|---|---|
TRUE OR FALSE AND FALSE |
true |
(TRUE OR FALSE) AND FALSE |
false |
Translated into a real GreenStore case: "I want the orders from France or Portugal that have been delivered". Written naively:
-- WRONG: interpreted as country='France' OR (country='Portugal' AND status='delivered')
... WHERE country = 'France' OR country = 'Portugal' AND status = 'delivered'
-- RIGHT
... WHERE (country = 'France' OR country = 'Portugal') AND status = 'delivered'The first version would give you every French order, delivered or not. It doesn't raise an error: it silently gives a wrong result, which is far worse.
Professional tip: even if you know the precedence table by heart, add parentheses whenever you mix
ANDandOR. They cost two characters and they remove the ambiguity for whoever reads your query later.
- Literals: strings, numbers, dates, booleans and NULL
A literal is a value written directly into the statement.
8.1. Text strings: SINGLE quotes
Strings go in single quotes. Always. This is probably the number one mistake of people coming from other programming languages, where "text" and 'text' are equivalent:
The message gives away what happened: PostgreSQL read the double quotes as an identifier, looked for a column called Food and didn't find it.
And what if the text contains a single quote, as in "L'Eliana"? You double it:
| town |
|---|
| L'Eliana |
PostgreSQL also supports dollar-quoted strings, very convenient for text full of quotes:
8.2. Numeric
SELECT 42; -- integer
SELECT -17; -- negative integer
SELECT 12.50; -- exact decimal (numeric type)
SELECT 1.2e3; -- scientific notation: 1200Numbers take no quotes. If you write '12.50' you're creating a text string that PostgreSQL will sometimes convert for you and sometimes not, with surprising results.
8.3. Dates and times
Dates are written as text strings, in single quotes, in ISO 8601 format (YYYY-MM-DD):
SELECT DATE '2026-02-14' AS the_date;
SELECT TIMESTAMP '2026-02-14 18:30:00' AS the_moment;
SELECT '2026-02-14'::DATE AS date_with_cast;| the_date |
|---|
| 2026-02-14 |
Always write in ISO format. If you write '14/02/2026', the engine will interpret it according to its regional setting (DateStyle), and on a server configured the American way '03/04/2026' can mean 3 April or 4 March. It's a mistake that never trips an alarm: it simply produces incorrect data. Date functions are covered thoroughly in module 6.
8.4. Booleans
PostgreSQL accepts several ways of writing them: TRUE/FALSE, 't'/'f', 'yes'/'no', '1'/'0'. Use plain TRUE and FALSE: it's the clearest.
Dialect note: MySQL has no real boolean type;
BOOLEANis an alias forTINYINT(1)whereTRUEis 1 andFALSEis 0. SQLite has no native boolean either: it stores them as 0 and 1.
8.5. NULL
NULL isn't a value: it's the absence of a value. In GreenStore, orders.employee_id is NULL when the order came in through the web and no sales rep handled it.
What matters right now, syntactically, is that NULL is not compared with =:
| with_equals | with_is_null |
|---|---|
| (null) | true |
NULL = NULL gives neither true nor false: it gives NULL, because comparing two unknowns lets you conclude nothing. That's why the IS NULL operator exists. The full semantics of nulls is studied in lesson 04-03; here it's enough to hold on to the rule: for nulls, IS NULL; never = NULL.
8.6. Summary of literals
| Type | How it's written | Correct example | Typical mistake |
|---|---|---|---|
| String | Single quotes | 'Natural cosmetics' |
"Natural cosmetics" (that would be an identifier) |
| Number | No quotes | 12.50 |
'12.50' |
| Date | Single quotes, ISO format | '2026-02-14' |
'14/02/2026' |
| Boolean | No quotes | TRUE |
'TRUE' |
| Null | The NULL keyword |
IS NULL |
= NULL |
- How to read a PostgreSQL error message
PostgreSQL's messages are among the best in the industry, but you have to know how to read them. They have up to four parts:
| Part | Meaning |
|---|---|
ERROR: |
The description of the problem |
at or near "FORM" |
The token where the parser got stuck |
LINE 2: |
The line of your statement |
^ |
The exact column |
The key is the phrase "at or near". PostgreSQL points at where it detected the problem, which very often is one position after where the error actually is. If the cursor points at something that looks correct, always look at the previous token.
A real example:
The cursor points at FROM, but FROM is spelled perfectly. The real error is a missing comma between name and price: PostgreSQL read price as an alias for name (an alias can be written without AS), and by the time it reached FROM nothing else fitted.
The most frequent errors when you start, and what they translate to:
| Message | What it really means | How to fix it |
|---|---|---|
syntax error at or near "X" |
Broken grammar at X or just before | Check the previous token: commas, parentheses, misspelled words |
relation "products" does not exist |
The table doesn't exist, or it's in another schema, or it was created with quotes and capitals | \dt to see the real name; check which database you're connected to |
column "Food" does not exist |
You used double quotes for a text string | Switch to single quotes |
column "prcie" does not exist |
Typo in the column name | \d products to see the exact names |
operator does not exist: text > integer |
You're comparing incompatible types | Check the column's type; use CAST (module 6) |
unterminated quoted string at or near "'..." |
An unclosed single quote | Count the quotes; if you're in psql, the '# prompt warns you |
division by zero |
You're dividing by 0 | Protect the divisor (NULLIF, module 6) |
Recommended procedure for any error:
- Read the whole message, not just the first word.
- Locate the line and the column with the
^. - If what's flagged looks correct, look at what comes before.
- Check the basics: commas, balanced parentheses, closed quotes, type of quote.
- If it's a name, verify it with
\d. - If the query is long, shrink it: remove clauses until it works and add them back one by one.
Common Mistakes and Tips
- Using double quotes for text.
"Valencia"is an identifier;'Valencia'is a string. If the error talks about a "column ... does not exist" with the text you wanted to search for, this is it. - Forgetting the comma between columns. It produces a
syntax error at or near "FROM"that throws you off badly, becauseFROMis fine. - Creating objects with
"QuotedCapitals". It condemns you to repeating the quotes forever. Use lowercasesnake_case. - Trusting
AND/ORprecedence. It doesn't raise an error, it gives wrong results. Add parentheses. - Unexpected integer division.
10 / 3is3. Convert to decimal before calculating percentages or averages. - Dates in local format. Always write
'YYYY-MM-DD'. - Comparing with
= NULL. It never returns rows, and it never warns you. UseIS NULL. - Tip: format before you debug. A single-line query is impossible to review; spread it over several lines and the error usually leaps out.
- Tip: test expressions with
SELECTand noFROM. Before dropping a calculation into a big query, check it in isolation:SELECT 12.50 * 1.21;. - Tip: when something fails, simplify. Remove half the query and see whether the error persists. It's the fastest way to narrow things down.
Exercises
Exercise 1
Find and fix four syntax errors in the following statement. State what message PostgreSQL would give in each case.
Exercise 2
Without running anything, predict the result of these five expressions and explain why. Then check them with SELECT.
SELECT 7 / 2;
SELECT 7.0 / 2;
SELECT TRUE OR FALSE AND FALSE;
SELECT 'Valencia' = 'valencia';
SELECT NULL = NULL;Exercise 3
Rewrite this query applying the course's style conventions (keywords in uppercase, one clause per line, conditions indented) and fix the logical flaw it contains: the intention was to get the orders that are paid or shipped whose shipping costs are above €5.
select id, status, shipping_cost from orders where status = 'paid' or status = 'shipped' and shipping_cost > 5;Solutions
Solution 1
The four errors, in order of appearance:
| # | Error | PostgreSQL message | Fix |
|---|---|---|---|
| 1 | Missing comma between name and price |
syntax error at or near "price" (or near FORM) |
SELECT name, price, stock |
| 2 | FORM instead of FROM |
syntax error at or near "FORM" |
FROM products |
| 3 | Double quotes around the string "Valencia" |
column "Valencia" does not exist |
= 'Valencia' |
| 4 | Missing final semicolon | In psql, the prompt stays at -# waiting |
Add ; |
There's also a semantic problem: GreenStore's products table has no city column (that column lives in customers and in employees). PostgreSQL would answer column "city" does not exist. Corrected version:
Solution 2
| Expression | Result | Reason |
|---|---|---|
7 / 2 |
3 |
Both operands are integers → integer division, truncated (not rounded) |
7.0 / 2 |
3.5000000000000000 |
7.0 is numeric, so the division is decimal |
TRUE OR FALSE AND FALSE |
true |
AND has higher priority: it evaluates TRUE OR (FALSE AND FALSE) = TRUE OR FALSE |
'Valencia' = 'valencia' |
false |
The data is case-sensitive, even though the syntax isn't |
NULL = NULL |
NULL |
Comparing two absences of value lets you conclude nothing; you have to use IS NULL |
Notice the contrast between rows 3, 4 and 5: in SQL case-insensitivity is a matter of grammar, never of values, and NULL doesn't behave like a normal value.
Solution 3
The logical flaw is precedence: AND is evaluated before OR, so the original query means status = 'paid' OR (status = 'shipped' AND shipping_cost > 5), and it would return every paid order, including the ones with free shipping. Corrected and formatted version:
SELECT id,
status,
shipping_cost
FROM orders
WHERE (status = 'paid' OR status = 'shipped')
AND shipping_cost > 5;The parentheses group the alternative statuses before the amount filter is applied. In module 4 you'll see an even more readable way of writing that first condition: status IN ('paid', 'shipped').
Conclusion
You now know the spelling rules of the language:
- A statement is made up of keywords, identifiers, literals, operators and expressions, organised into clauses and closed by
;. - Keywords are case-insensitive, but unquoted identifiers are folded to lowercase in PostgreSQL: that's why the course uses lowercase
snake_caseand avoids double quotes. - You comment with
--and/* */, and you format with one clause per line because SQL is written once and read many times. - You've got a grip on the arithmetic, comparison and logical operators, and you know
ANDcomes beforeOR: that's why you add parentheses. - You can tell the literals apart: single quotes for text and ISO dates, no quotes for numbers and booleans, and
IS NULLfor nulls. - You know how to read a PostgreSQL error: message, token, line and cursor, remembering that the fault is usually just before what's flagged.
In the next lesson, Understanding Databases and Tables, we'll move from form to content: what exactly a database, a schema, a table, a row and a column are; what data types PostgreSQL offers and how to choose the right one (including why money must never be stored in a FLOAT); and how to inspect the structure of tables that already exist.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
