You closed module 1 with the GreenStore database loaded and verified, and with a promise: the first real query was one lesson away. Here it is. In this lesson you'll learn the most important statement in SQL, the one you'll use 90 % of the times you sit down in front of a database: SELECT. You'll see how to ask for specific columns, why SELECT * is convenient for exploring but dangerous in production, what the result set the engine hands back really is and —most important in the medium term— in what logical order PostgreSQL executes the clauses of a query, which is not the order you write them in. That last point looks theoretical today and will be the key to understanding modules 4 and 7.
Contents
- The minimal query:
SELECT ... FROM ... - Selecting specific columns
SELECT *: when yes and why not in production- The column order is decided by the
SELECT SELECTwithoutFROM: SQL as a calculator- What the result set really is
- The logical execution order of a query
- Reading results in
psqland the expanded mode\x - Common Mistakes and Tips
- Exercises
- Conclusion
- The minimal query:
SELECT ... FROM ...
SELECT ... FROM ...A read query needs, as a minimum, two clauses:
| name |
|---|
| Food |
| Natural cosmetics |
| Sustainable home |
| Drinks |
| Personal hygiene |
| Supplements |
Read it out loud backwards and you'll understand better what the engine does: "from the categories table, give me the name column".
| Clause | Role |
|---|---|
SELECT <column list> |
Projection: which columns you want in the result |
FROM <table> |
Source: where the rows come from |
In the relational-model jargon you saw in 01-05, SELECT performs a projection (choosing columns) and, when we add WHERE in lesson 02-03, we'll also perform a selection (choosing rows). That the keyword is called SELECT and does the projection is one of SQL's small historical inconsistencies; it's worth knowing so you don't get confused reading academic literature.
Notice you haven't told PostgreSQL how to walk the table, nor which file it's in, nor whether an index would help. You've only described the what. That's the declarative character of the language we saw in 01-01.
- Selecting specific columns
To ask for several columns you separate them with commas:
| id | name | price |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 12.50 |
| 2 | Organic brown rice 1 kg | 3.90 |
| 3 | Raw orange blossom honey 500 g | 9.75 |
| 4 | Spelt pasta 500 g | 2.80 |
| 5 | Organic crushed tomato 400 g | 1.95 |
| 6 | Aloe vera face cream 50 ml | 18.90 |
| 7 | Rosemary solid shampoo 80 g | 8.40 |
| 8 | Almond body oil 200 ml | 14.25 |
| 9 | Calendula lip balm 15 ml | 4.60 |
| 10 | Concentrated eco laundry detergent 1 L | 11.20 |
| 11 | Loofah scrubber (pack of 3) | 5.50 |
| 12 | Reusable cotton bags (pack of 5) | 9.90 |
| 13 | Soy wax candles (pack of 2) | 13.75 |
| 14 | Organic chamomile tea 20 bags | 3.25 |
| 15 | Ceremonial matcha green tea 30 g | 22.00 |
| 16 | Ginger kombucha 750 ml | 4.95 |
| 17 | Cold-pressed orange juice 1 L | 5.40 |
| 18 | Bamboo toothbrush | 3.50 |
| 19 | Natural stick deodorant 50 g | 7.80 |
| 20 | Spirulina capsules 120 units | 16.40 |
20 rows. Exactly the 20 you loaded in 01-06: SELECT with no filter returns all the rows of the table, and projects only the columns you've named. No cost, no stock, no active, no added_date: you didn't ask for them.
Writing details you already know from 01-03 and that apply here:
- The comma goes between columns, never after the last one.
SELECT id, name, FROM productsgivessyntax error at or near "FROM". - Putting one column per line isn't a whim: when the list grows to fifteen columns, you'll be glad you can add or remove one without rewriting the whole line.
- The names have to exist. If you write
SELECT selling_price FROM products, PostgreSQL answerscolumn "selling_price" does not exist. A\d productsclears it up in a second.
SELECT *: when yes and why not in production
SELECT *: when yes and why not in productionThe asterisk means "every column of the table, in the order they were defined":
| id | name | description |
|---|---|---|
| 1 | Food | Organic dry food and preserves |
| 2 | Natural cosmetics | Cosmetics with natural ingredients and no parabens |
| 3 | Sustainable home | Cleaning and household goods in reusable materials |
| 4 | Drinks | Organic teas, juices and fermented drinks |
| 5 | Personal hygiene | Daily hygiene with minimal or compostable packaging |
| 6 | Supplements | Plant-based dietary supplements |
It's convenient, and to explore a table you don't know it's the first thing anyone writes. The problem shows up when that * ends up inside an application, a report or a view.
Risk of SELECT * in production |
What happens in practice |
|---|---|
| Fragile contract | If tomorrow somebody adds a column to products, your code receives an extra column it doesn't expect; if somebody reorders them, access by position gives you the wrong value |
| Unnecessary traffic and memory | You fetch description (TEXT) even though you only want name. Multiplied by millions of rows, that's money |
| It breaks optimisations | An index that contains every column you ask for allows an index-only scan (module 8). With * that's almost never possible |
| Unreadable in review | Whoever reads the query can't tell which data is actually needed |
Ambiguity with JOIN |
With two tables joined, * returns two columns called name and no clean way to tell them apart (module 3) |
Course rule:
SELECT *to explore inpsql; an explicit column list in any query you're going to save, version or run more than once.
There is an intermediate variant that is both common and correct: SELECT table.* in queries with several tables, to say "every column of this table". You'll see it in module 3.
- The column order is decided by the
SELECT
SELECTThe result doesn't have to respect the physical order of the table. Your list rules:
| description | name | id |
|---|---|---|
| Organic dry food and preserves | Food | 1 |
| Cosmetics with natural ingredients and no parabens | Natural cosmetics | 2 |
| Cleaning and household goods in reusable materials | Sustainable home | 3 |
| Organic teas, juices and fermented drinks | Drinks | 4 |
| Daily hygiene with minimal or compostable packaging | Personal hygiene | 5 |
| Plant-based dietary supplements | Supplements | 6 |
And nothing stops you from repeating a column, though it's rarely useful:
It returns three columns, two of them called id. PostgreSQL allows it; the clients and programming languages that access by name don't always. It's a hint that as soon as there are repeated names you'll need aliases, which is exactly the subject of the next lesson.
SELECT without FROM: SQL as a calculator
SELECT without FROM: SQL as a calculatorIn PostgreSQL the FROM clause is optional. Without it, SELECT evaluates expressions and returns a single row:
| ?column? |
|---|
| 4 |
That ?column? header means "I don't know what to call this": the expression has no name. You fix it with an alias (lesson 02-02).
Examples you'll use daily to try things out before dropping them into a big query:
SELECT current_date; -- the server's date
SELECT 12.50 * 1.21; -- price with VAT
SELECT 3 * 12.50 * (1 - 0.05); -- amount of a line with a 5 % discount
SELECT 'Lucía' || ' ' || 'Martínez'; -- text concatenation| current_date |
|---|
| 2026-08-02 |
| ?column? |
|---|
| 15.1250 |
| ?column? |
|---|
| 35.6250 |
| ?column? |
|---|
| Lucía Martínez |
Three things already visible here that will come back:
current_datereturns the date of the day you run the query; the value above is only an example.12.50 * 1.21gives15.1250, with four decimals. PostgreSQL adds the scales of the operands when multiplyingNUMERIC. To present money you'll have to round (lesson 02-02).3 * 12.50 * (1 - 0.05)is the calculation of the amount of line 18 oforder_lines, with the numbers typed out by hand. In the next lesson you'll write it with columns instead of literals.
Dialect note: Oracle requires a
FROM, which is why there you writeSELECT 2+2 FROM dual;. MySQL, SQL Server, SQLite and PostgreSQL all acceptSELECTwithoutFROM.
- What the result set really is
What a query returns is called the result set and, conceptually, it's a relation: a temporary, unnamed table that exists only for as long as the query lasts. Three properties follow from that which are worth internalising today:
- It's a table like any other. It has columns with names and types, and rows. That's why it will be usable as the source of another query (subqueries in
FROM, module 7) or combined with another relation (UNION, module 3). - It modifies nothing.
SELECTis a read-only operation: however many times you run it, the original table doesn't change. Modifying data is module 5. - It has no guaranteed order. This is the one that surprises people most.
On the third point: in the result of section 2 the products came out ordered by id, from 1 to 20. It looks like the table "is sorted". It isn't. That order is a side effect of the rows having been inserted one after another and PostgreSQL having read them in physical order. As soon as the table grows, rows are updated, an index comes into play or the engine uses several parallel processes, the order can change without warning and without anything going wrong.
The rule is blunt and admits no exceptions:
If the order matters, write
ORDER BY. If you don't write it, you have no right to expect any particular order.
The ORDER BY clause is lesson 02-05. Until then, when in this lesson or the next you see results "ordered by id", understand that it's the order you'll probably see, not the one the engine promises you.
- The logical execution order of a query
You write a query in one order and the engine resolves it in another. Understanding that difference is what later makes obvious things that otherwise look arbitrary (why an alias works in ORDER BY but not in WHERE, why HAVING exists on top of WHERE, why a subquery can see certain columns and not others).
The order you write it in:
The order it's logically executed in:
flowchart LR
A["1 · FROM<br/>where the rows come from"] --> B["2 · WHERE<br/>which rows stay"]
B --> C["3 · SELECT<br/>which columns are projected"]
C --> D["4 · ORDER BY<br/>in what order they come back"]
D --> E["5 · LIMIT<br/>how many are returned"]
| Step | Clause | What it does | Lesson |
|---|---|---|---|
| 1 | FROM |
Determines the starting set of rows | 02-01 |
| 2 | WHERE |
Discards rows that don't meet the condition | 02-03 |
| 3 | SELECT |
Computes and projects the result's columns | 02-01 / 02-02 |
| 4 | ORDER BY |
Sorts the already-projected result | 02-05 |
| 5 | LIMIT |
Trims how many rows are handed back | 02-06 |
For now you're only using steps 1 and 3, so the diagram looks over the top. Keep it: we'll add clauses to this same scheme in every lesson of the module, and in module 4 we'll bring in GROUP BY and HAVING.
Two consequences you can already anticipate:
- Since
WHEREruns beforeSELECT, when the time comes to filter you won't be able to use inWHEREa name invented in theSELECT: it doesn't exist yet. - Since
ORDER BYruns afterSELECT, there you will be able to use it.
An important nuance: this is the logical order, the one that defines the meaning of the query. The actual execution plan the optimizer picks can be very different (reading an index, filtering while reading, stopping early), as long as the result is the same as the logical order's. Real plans are studied in module 8 with EXPLAIN.
- Reading results in
psql and the expanded mode \x
psql and the expanded mode \xWhen you run a query in psql you see something like this:
greenstore=> SELECT id, name, price FROM products LIMIT 6; id | name | price ----+-------------------------------+-------- 1 | Extra virgin olive oil 500 ml | 12.50 ... (6 rows)
The pieces of that output:
| Element | Meaning |
|---|---|
| First line | The names of the result's columns |
| Line of hyphens | Separator |
| Alignment | Numbers align right, text aligns left: a visual hint about the data type |
(6 rows) |
How many rows the query returned. Always look at it: it's the first check of whether your query does what you think |
| Empty cell | A NULL is shown as blank space (configurable with \pset null '(null)') |
Booleans print as t and f, not as true/false. In this course we'll write them as true/false in the result tables for readability.
When a row has many columns or long blocks of text, the output falls apart and becomes unreadable. That's what expanded mode is for:
-[ RECORD 1 ]--+--------------------------- id | 1 name | Lucía last_name | Martínez Soler email | [email protected] city | Valencia country | Spain signup_date | 2025-01-10 referred_by_id | -[ RECORD 2 ]--+--------------------------- id | 2 name | Carlos last_name | Ferrer Ibáñez email | [email protected] city | Valencia country | Spain signup_date | 2025-01-22 referred_by_id | 1
Each row now takes up a vertical block. You can see perfectly that customer 1 has an empty referred_by_id (it's NULL: they arrived on their own) and customer 2 was referred by customer 1.
\x toggles between on and off; \x auto lets psql decide based on the terminal width, and it's the most comfortable option day to day.
Other metacommands that will save you time in this module:
| Metacommand | What for |
|---|---|
\d products |
To remember the exact column names |
\x auto |
Expanded mode only when it's needed |
\timing on |
To see how long each query takes (useful from module 8) |
\e |
To edit the last query in your text editor |
\g |
To rerun the last query |
- First real queries on GreenStore
Close the lesson by practising projection on the three tables you'll use most.
Who the customers are and where they're from:
| name | last_name | city | country |
|---|---|---|---|
| Lucía | Martínez Soler | Valencia | Spain |
| Carlos | Ferrer Ibáñez | Valencia | Spain |
| Marta | Sanchis Gil | Castellón | Spain |
| Javier | Ortega Ruiz | Madrid | Spain |
| Ana | Belmonte Roca | Barcelona | Spain |
| Pau | Llorens Vidal | Valencia | Spain |
| Sofia | Moreira Costa | Lisbon | Portugal |
| Tiago | Almeida Nunes | Porto | Portugal |
| Camille | Dubois | Lyon | France |
| Julien | Moreau | Paris | France |
| Elena | Navarro Puig | Alicante | Spain |
| Diego | Ramos Herrera | Seville | Spain |
| Núria | Bosch Ferrer | Barcelona | Spain |
| Hugo | Iglesias Pardo | Zaragoza | Spain |
| Inés | Carrasco Vega | Valencia | Spain |
The team, with its hierarchy in raw form:
| id | name | last_name | job_title | manager_id |
|---|---|---|---|---|
| 1 | Rosa | Alcázar Vives | General manager | (null) |
| 2 | Andrés | Company Talens | Sales manager | 1 |
| 3 | Beatriz | Nadal Ripoll | Logistics manager | 1 |
| 4 | Óscar | Peris Blasco | Sales rep | 2 |
| 5 | Laia | Puig Sanchis | Sales rep | 2 |
| 6 | Marc | Estévez Roig | Customer support | 2 |
| 7 | Irene | Salvador Mira | Warehouse operator | 3 |
| 8 | Daniel | Vercher Lluch | Data analyst | 1 |
There's Rosa Alcázar Vives's NULL, the only one with no manager. Seeing it as an empty cell is your first practical contact with nulls: module 4 devotes a whole lesson to them.
The status of the orders:
| id | customer_id | order_date | status |
|---|---|---|---|
| 1 | 1 | 2025-03-04 | delivered |
| 2 | 2 | 2025-03-12 | delivered |
| 3 | 3 | 2025-04-02 | delivered |
| 4 | 4 | 2025-04-19 | delivered |
| 5 | 1 | 2025-05-07 | delivered |
| 6 | 5 | 2025-05-23 | cancelled |
| 7 | 6 | 2025-06-11 | delivered |
| 8 | 7 | 2025-06-28 | delivered |
| 9 | 8 | 2025-07-15 | delivered |
| 10 | 9 | 2025-08-03 | delivered |
| 11 | 2 | 2025-09-09 | delivered |
| 12 | 10 | 2025-10-01 | delivered |
| 13 | 11 | 2025-10-22 | delivered |
| 14 | 12 | 2025-11-14 | delivered |
| 15 | 1 | 2025-12-02 | delivered |
| 16 | 4 | 2025-12-19 | shipped |
| 17 | 7 | 2026-01-13 | shipped |
| 18 | 5 | 2026-01-27 | paid |
| 19 | 6 | 2026-02-09 | paid |
| 20 | 9 | 2026-02-21 | pending |
Notice that customer_id is a number, not a name. To know that order 10 is Camille Dubois's you have to go to customers, and that means combining two tables: which is exactly what module 3's JOIN does. Until then we'll always work with one table at a time.
Common Mistakes and Tips
- A stray comma before
FROM.SELECT id, name, FROM products→syntax error at or near "FROM". The error points atFROM, but the fault is in the comma before it (the "look at the previous token" rule from 01-03). - A missing comma between columns.
SELECT name price FROM productsraises no error: PostgreSQL readspriceas an alias fornameand returns a single column calledpriceholding the product names. It's a silent failure; always count the columns in the result. column "..." does not exist. It's almost always a typo or a column that lives in another table.\d tablebefore rewriting blindly.- Mixing up quotes.
SELECT "name" FROM productsworks (a lowercase identifier);SELECT 'name' FROM productsreturns 20 rows holding the literal textname. They aren't the same thing. - Assuming the result comes sorted. It works today with 20 rows and fails the day the table has a million.
ORDER BYor nothing. - Leaving
SELECT *in the code. Use it to explore, replace it with the column list as soon as the query is final. - Tip: always look at the row count. If you expected 20 and
psqlsays(0 rows), you've learned something before reading a single cell. - Tip: test expressions without
FROM.SELECT 3 * 12.50 * (1 - 0.05);validates the calculation in a second, with no table noise. - Tip: turn on
\x auto. It's the difference between reading a row ofcustomersand wrestling with your terminal.
Exercises
Exercise 1
Write a query that returns the name, the country and the email of every supplier. Then answer: how many rows does it return and why is no filter needed to get them all?
Exercise 2
On employees, write a query that shows the columns in this exact order: job_title, last_name, name, city. Justify why the result doesn't match the order the columns are defined in within the table.
Exercise 3
Without using any table, calculate these three things with SELECT and explain the result:
- The price with VAT (21 %) of product 15 (Ceremonial matcha green tea, €22.00).
- The amount of line 27 of
order_lines: 8 units at €1.95 with a discount of 0.15. - The gross margin of product 6 (price 18.90, cost 9.50).
Solutions
Solution 1
| name | country | |
|---|---|---|
| Huerta del Turia | Spain | [email protected] |
| BioSierra Ibérica | Spain | [email protected] |
| Verde Atlántico | Portugal | [email protected] |
| Maison Nature | France | [email protected] |
| EcoNordic Supplies | Germany | [email protected] |
It returns 5 rows. A query with no WHERE discards no rows: step 2 of the logical order simply doesn't exist, so everything that comes out of FROM reaches SELECT. Notice that supplier 5 shows up even though it has active = FALSE: nothing excludes it. Filtering it out will be the job of lesson 02-03.
Solution 2
| job_title | last_name | name | city |
|---|---|---|---|
| General manager | Alcázar Vives | Rosa | Valencia |
| Sales manager | Company Talens | Andrés | Valencia |
| Logistics manager | Nadal Ripoll | Beatriz | Valencia |
| Sales rep | Peris Blasco | Óscar | Valencia |
| Sales rep | Puig Sanchis | Laia | Castellón |
| Customer support | Estévez Roig | Marc | Valencia |
| Warehouse operator | Salvador Mira | Irene | Valencia |
| Data analyst | Vercher Lluch | Daniel | Valencia |
The reasoning: the table defines the columns as id, name, last_name, job_title, manager_id, salary, hire_date, city, but only SELECT * uses that order. When you list columns, the result's order is your list's, because the projection builds a new relation with the shape you decide. It's the same reason you can omit columns or repeat them.
Solution 3
SELECT 22.00 * 1.21 AS vat_matcha,
8 * 1.95 * (1 - 0.15) AS amount_line_27,
18.90 - 9.50 AS margin_product_6;| vat_matcha | amount_line_27 | margin_product_6 |
|---|---|---|
| 26.6200 | 13.2600 | 9.40 |
Three observations about the reasoning:
22.00 * 1.21gives 26.6200, not26.62. When multiplying twoNUMERICvalues, PostgreSQL adds the scales: two decimals by two decimals give four. For a report you'll have to round.- The discount is a fraction, so
0.15is 15 % and the factor applied is(1 - 0.15) = 0.85. Writing8 * 1.95 * 0.15would give the discount, not the amount. It's the most frequent misreading with this database. - The subtraction
18.90 - 9.50keeps two decimals, because in addition and subtraction the result's scale is the larger of the two, not the sum.
(Yes, we've used AS ahead of time: without it the three columns would all be called ?column?. It's the first clause of the next lesson.)
Conclusion
You now know how to interrogate a table:
- The minimal query is
SELECT columns FROM table;:FROMsays where the rows come from andSELECTwhich columns are projected. SELECT *is for exploring, but in production it's replaced by the explicit column list: a stable contract, less traffic and better execution plans.- The column order of the result is decided by you with your list, not by the table's definition.
SELECTwithoutFROMturns PostgreSQL into a calculator for testing expressions before working them into a query.- The result set is a temporary, read-only relation with no guaranteed order: if the order matters,
ORDER BY. - You know the logical execution order
FROM → WHERE → SELECT → ORDER BY → LIMIT, which we'll complete in every lesson of this module. - You can read
psql's output, count rows and use expanded mode\xwhen the rows are wide.
In the next lesson, Aliases, Expressions and Calculated Columns, you'll stop merely returning what's stored and start computing: prices with VAT, margins, percentage margins and the amount of an order line, the expression that will stay with you throughout the course. Along the way you'll give those columns decent names with AS and discover, thanks to the logical order you've just learned, why an alias works in some places and not in others.
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
