The previous lesson's queries work. This lesson is about the other thing: whether a year from now, when you no longer remember why you wrote that LEFT JOIN, somebody —including you— will be able to touch them without fear. Because SQL has a quirk that makes it especially treacherous: a badly written query doesn't fail, it returns numbers. A badly written program blows up and you find out; a query with one JOIN too many returns a plausible figure that ends up on a slide in front of management.
There's no new syntax here. There's judgement: how things get named, how they get formatted, what you decide when designing the schema, which guarantees go in the database and which in the application, and what process surrounds all of it. Nearly all of it is convention, and with a convention what matters isn't which one you pick, but that the whole team picks the same one. The lesson ends with the catalogue of antipatterns and with a checklist for reviewing a query before you call it done.
Contents
- Naming
- Formatting and readability
- Schema design
- Reliability: where the guarantees live
- Process: versioning, review, testing and backups
- Antipatterns
- Query review checklist
- Common Mistakes and Tips
- Exercises
- Conclusion
- Naming
Singular or plural, and why it doesn't matter
The oldest argument in the trade: customer or customers? There are honest arguments for both:
Plural (customers) |
Singular (customer) |
|
|---|---|---|
| Reasoning | A table is a collection of rows | A row is a customer; the table is the type |
| Reads well in | SELECT * FROM customers |
JOIN customer ON customer.id = ... |
| Used by | Rails, Django (by default), this course | Hibernate/JPA by habit, many DBAs |
Neither is better. What is objectively bad is mixing them: a schema with customers, order and order_lines forces you to check the dictionary before every query. GreenStore uses plural on every table, without exception, and that's the entire merit of the decision.
The rules that really are objective
- Lowercase
snake_case, always. PostgreSQL folds to lowercase any identifier not wrapped in double quotes: if you create"OrderDate", you'll have to write it quoted forever, and the first time you forget you'll getcolumn "orderdate" does not exist. Putting double quotes in aCREATE TABLEis a life sentence. - ASCII only in identifiers. That's why the whole schema sticks to plain ASCII names (01-06). The data carries accents and
ñ; the object names don't. - No reserved words.
user,order,group,table,select,check,end... A column calledorderforces you to quote it in every query. If the business says "order", the table isorders; if it says "user",users. - Names that say something.
data,table1,temp2,info,field3,xmean nothing six months later. And no home-made abbreviations:order_date, notord_dt. - No type prefixes.
str_name,tbl_customers,int_stockare Hungarian notation: noise that also lies the moment somebody changes the type.
Keys, constraints and indexes
GreenStore's convention, which is the most widespread and the one this course has used across eleven modules:
| Object | Convention | Example from the course |
|---|---|---|
| Table | Plural, snake_case |
order_lines |
| Primary key | id, surrogate |
products.id |
| Foreign key | <singular_table>_id |
orders.customer_id, order_lines.product_id |
| Self-referencing FK | The name of the role, not the table | employees.manager_id, customers.referred_by_id |
| Boolean | A positive adjective, never negated | active (never not_active) |
| Date | <what>_date |
order_date, signup_date, added_date |
CHECK constraint |
chk_<table>_<column> |
chk_reviews_rating |
| Foreign key (constraint) | fk_<table>_<referenced_table> |
fk_orders_customers |
UNIQUE |
uq_<table>_<columns> |
uq_customers_email |
| Index | idx_<table>_<columns> |
idx_order_lines_order_id |
| View / materialized view | v_ / mv_ |
v_sales_detail, mv_monthly_sales |
| Function / procedure / trigger | fn_ / sp_ / trg_ |
fn_order_total, sp_confirm_order |
Two notes. First: a self-referencing FK is named after the role. employees.employee_id says nothing; manager_id says everything. Second: a negative boolean is a trap —WHERE NOT not_active is unreadable and not_active = FALSE is worse—; always name the true condition.
Name your constraints by hand. If you don't, PostgreSQL generates
products_price_checkororders_customer_id_fkey, which are readable but not under your control: they change if the column name changes, and they show up verbatim in the error message the user will see. WithCONSTRAINT chk_products_price_positive CHECK (price >= 0), the error says which rule was violated and the application can map it to a decent message.
- Formatting and readability
A query is written once and read twenty times. Compare:
-- ⚠️ INCORRECT (not because of the result, but because of what it costs to read and change)
select c.name,sum(l.quantity*l.unit_price*(1-l.discount)) from categories c,products p,
order_lines l where c.id=p.category_id and p.id=l.product_id group by c.name order by 2 desc;
-- ✅ CORRECT
SELECT cat.name AS category,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM order_lines AS ol
JOIN products AS p ON p.id = ol.product_id
JOIN categories AS cat ON cat.id = p.category_id
GROUP BY cat.name
ORDER BY revenue DESC;| category | revenue |
|---|---|
| Food | 256.27 |
| Drinks | 195.28 |
| Natural cosmetics | 156.32 |
| Sustainable home | 88.58 |
| Personal hygiene | 31.50 |
Five categories: Supplements doesn't appear because its only product is discontinued and was never sold, and an INNER JOIN doesn't invent rows. Both queries return the same thing; only one can be modified without rereading it end to end. The rules the second one applies:
- One clause per line, with the keywords left-aligned. Your eyes find the
WHEREwithout looking for it. - Explicit
JOIN, never the comma.FROM a, b WHERE a.id = b.a_idis 1989 syntax: it mixes the join with the filter and, if you forget the condition, it produces a silent Cartesian product (03-06). WithJOIN ... ON, join and filter are separate. - Meaningful aliases.
ol,p,catare understandable;a,b,cforce you to scroll up and look. The course's have been fixed since module 3 and never change. - Explicit
ASon column aliases. It's optional in PostgreSQL, and leaving it out means a forgotten comma turnsprice, costintoprice AS cost, a bug no tool will catch. - Keywords in uppercase, identifiers in lowercase. It isn't cosmetic: at a glance it separates the language from your data.
JOINcondition in a consistent order:new_table.column = already_known_table.column. Read as a chain, it tells the story of the path.
Comments: the why, not the what
-- ⚠️ Useless: repeats what the code already says
-- Sums the amount of the lines
SELECT SUM(ol.quantity * ol.unit_price * (1 - ol.discount)) FROM order_lines AS ol;
-- ✅ Useful: explains a decision you can't infer from the code
-- We use unit_price and not products.price: it's the HISTORICAL sale price.
-- Orders 1 and 2 predate the April 2025 price rise.
SELECT SUM(ol.quantity * ol.unit_price * (1 - ol.discount)) FROM order_lines AS ol;And for things that belong to the schema, PostgreSQL has the right place: COMMENT ON, which stays inside the database and is visible to anyone with \d+.
COMMENT ON COLUMN order_lines.discount IS 'Fraction between 0 and 1 (0.10 = 10 %), not a percentage';
COMMENT ON TABLE reviews IS 'Identifiers are plain ASCII by convention; the data itself does carry accents';Automatic formatters
Arguing about indentation in a code review is wasted time. Delegate it:
| Tool | What it is | Note |
|---|---|---|
pgFormatter (pg_format) |
A formatter for PostgreSQL, in Perl | Very configurable; there's a plugin for the usual editors |
| sqlfluff | A linter and formatter, in Python | Also detects bad practices; understands dbt/Jinja templates |
| The IDE's formatter | DBeaver, DataGrip, pgAdmin | Convenient, but each one formats differently |
The recommendation: fix a style in a configuration file inside the repository (.sqlfluff, .pg_format) and run it in the pre-commit hook or in continuous integration. That way style stops being an opinion and becomes an automatic check.
- Schema design
Normalize by default, denormalize with a reason
The rule, picking up 01-05 and 05-06: start normalized. Normalization removes redundancy, and redundancy is what lets two copies of the same data end up saying different things. Denormalize only when you have a measurement that justifies it and a mechanism that keeps the copy in sync.
GreenStore has both cases, and they're worth telling apart:
| Case | What it is | Verdict |
|---|---|---|
order_lines.unit_price |
Looks like a copy of products.price |
Not denormalization: it's a different, historical piece of data. March's sale price isn't today's |
orders.total (module 10) |
A precomputed sum of the lines | It is: you have to maintain it with a trigger, and if the trigger fails the row lies |
The question that settles it: can the value change on its own after being stored? If it can't —the price it was sold at— it isn't redundancy, it's history, and it should be stored. If it can —the order total, the average rating— it's an aggregate and it has a maintenance cost.
Types: the most restrictive one that works
A type is the cheapest constraint there is, because checking it costs nothing.
| Instead of | Use | Because |
|---|---|---|
TEXT for everything |
VARCHAR(n), INTEGER, DATE, NUMERIC |
A TEXT accepts "three" in a quantity |
FLOAT/REAL for money |
NUMERIC(10,2) |
Floating point = rounding errors (01-04) |
VARCHAR for dates |
DATE / TIMESTAMPTZ |
'31/02/2025' fits in a VARCHAR |
INTEGER 0/1 for flags |
BOOLEAN |
active = 2 shouldn't exist |
VARCHAR(255) out of habit |
The domain's real length | 255 is a MySQL legacy, not a measurement |
NOT NULL by default, NULL as a decision
Picking up 04-03: a NULL is a source of complexity that propagates through the whole application —aggregates that ignore it, comparisons that are neither true nor false, a NOT IN that returns zero rows. That's why the default stance should be NOT NULL, and every nullable column should be a decision you can defend.
GreenStore has three nullable columns and all three have an explicit meaning: orders.employee_id = a web order with no sales rep; customers.referred_by_id = they arrived on their own; employees.manager_id = general management. None of them means "we don't know yet" or "the form came in empty", and that's the difference: if a NULL can mean two different things, the design is wrong.
Surrogate versus natural keys, and EAV
Surrogate key (id) |
Natural key (email, tax id) |
|
|---|---|---|
| Stability | Total: nothing changes it | The customer changes email address |
| FK size | Small (4-8 bytes) | The text's size, repeated in every child table |
| Readability of an FK | None: you have to join to know who it is | Reads directly |
| Business uniqueness | Doesn't guarantee it: you need a separate UNIQUE |
Guarantees it by definition |
The criterion: a surrogate id as the PK, and the natural key protected with UNIQUE. That's exactly what customers does (PK id, UNIQUE(email)), and that way you get both: stability and business uniqueness.
And the antipattern to recognize so you can run from it: EAV (Entity-Attribute-Value), an attributes(entity_id, attribute_name, value) table that promises infinite flexibility. The price: everything is text, there are no types and no constraints, querying three attributes means three self-joins, and nobody knows which attributes exist. If the fields genuinely vary, the modern answer is JSONB (10-06); if they don't vary, they're columns.
CHECK or a lookup table instead of free text
orders.status could just be a VARCHAR, and six months later you'd have delivered, Delivered, DELIVERED and deliverd. There are two ways to prevent it:
CHECK (status IN (...)) |
Lookup table + FK | |
|---|---|---|
| Adding a value | ALTER TABLE (a migration, 05-06) |
An INSERT |
| Storing attributes of the value | Can't | Yes: order, colour, whether it's final |
| Translating into other languages | No | Yes |
| When to use it | A closed and short list that almost never changes | A list that grows or has attributes of its own |
GreenStore uses a CHECK for status and payment_method because they're five and four values that are part of the business model. A catalogue of countries or of categories, on the other hand, is a table.
- Reliability: where the guarantees live
The rule that separates a healthy database from a sick one: integrity lives in the database, not just in the application.
It's tempting to think "I already validate it in the form". That isn't enough, for four reasons that always come true: a second application will arrive (the admin panel, a migration script, an integration); somebody will run an UPDATE by hand in psql one night; there will be a bug in the application's validation; and there will be concurrency, and a "read then write" check done in the application has a race condition that only a UNIQUE constraint really closes (09-04).
| Guarantee | The right place | In GreenStore |
|---|---|---|
| "This customer exists" | FK | orders.customer_id REFERENCES customers(id) |
| "There are no two identical emails" | UNIQUE |
customers.email |
| "The rating runs from 1 to 5" | CHECK |
chk_reviews_rating |
| "The field is required" | NOT NULL |
orders.order_date |
| "You can't order more stock than there is" | A transaction + a lock, or a trigger | sp_confirm_order (10-04) |
| "The error message should look nice" | The application | Translating the constraint's error |
The application also validates: to give useful messages and to avoid pointless round trips to the server. But it validates as well, not instead.
Two more details. Sensible DEFAULTs: stock DEFAULT 0, active DEFAULT TRUE, discount DEFAULT 0 stop an incomplete INSERT putting nulls where they don't belong. And TIMESTAMPTZ in UTC for every instant: a TIMESTAMP with no time zone stores a meaningless number, and in a shop selling to Spain, Portugal and France you pay for that on the day the clocks change. Store in UTC, convert when displaying. Pure calendar dates —order_date, signup_date— really are DATE, because 4 March is 4 March everywhere.
- Process: versioning, review, testing and backups
The schema is code. Everything in lesson 05-06 boils down to one sentence: if the production schema can't be rebuilt from the repository, you don't have version control.
- Numbered, versioned, immutable migrations. Every change is a file (
V007__add_index_orders_date.sql), it goes into Git with the code that needs it, and it isn't edited once applied: you fix it with a new migration. Tools: Flyway, Liquibase, Alembic, or the ORM's migrations (11-05). - Every migration with its way back, or at least with a written plan of what to do if it fails. And breaking changes go through 05-06's expand/contract pattern: add, deploy, migrate data, and only then remove.
- Code review for the SQL too. A report query deserves the same review as a function: it's just as easy to get wrong and far harder to notice. A
JOINthat duplicates rows produces numbers, not exceptions. - A test environment with realistic data. Realistic in volume and shape —a plan over 20 rows tells you nothing about what will happen with 20 million (08-05)— but fictional or anonymized.
⚠️ Don't copy personal data from production into development. It's the most widespread practice and one of the most dangerous: it multiplies copies of real data across laptops, unencrypted environments and dumps nobody ever deletes. Generate synthetic data, or pseudonymize before copying (replace names and emails, shift dates, round amounts) — with the warning that badly done pseudonymization is reversible. Before moving personal data between environments, check with your organization's data protection officer or legal counsel. It's the same warning as in 05-04, and it applies here too.
- Tested backups. A backup that has never been restored isn't a backup: it's a file about which you assume things. Schedule a periodic test restore in a separate environment and measure how long it takes, because that number is your real recovery time. Check too that the backup includes what you think it does (roles, extensions, sequences) and that the retention covers the time it takes you to spot a problem: if a deletion is discovered after ten days and you keep seven, there is no backup.
- Slow-query monitoring.
pg_stat_statementssorted by total time —not by average time, which hides the N+1 (08-04)—,log_min_duration_statementto log anything over a threshold, and a periodic review of the ranking. Without this you don't find out something is wrong: an angry user tells you.
- Antipatterns
| Antipattern | Why it hurts | What to do |
|---|---|---|
SELECT * in production |
It fetches columns nobody uses, prevents an Index Only Scan, and breaks when somebody adds a column |
List the columns. SELECT * only for exploring in psql (08-04) |
| The same business rule in five places | The day VAT changes you have to find all five | A view or a function as the single definition (10-01, 10-04) |
DELETE/UPDATE with no WHERE in production |
Wipes the whole table, end of story | BEGIN first, SELECT the same condition, check the count and then COMMIT (05-04). And \set AUTOCOMMIT off in psql |
| Building SQL by concatenating strings | SQL injection, plain and simple | Parameterized queries → 11-03 |
| Queries inside a loop | The N+1: 21 or 501 queries to render one screen | A JOIN, or the ORM's eager loading → 11-05 |
| "Just in case" indexes | Every index slows down all writes and takes up disk; the unused ones are pure cost | Create them with a specific query in front of you; review pg_stat_user_indexes (08-02) |
| "I'll just fix it directly in production" | The change isn't in the repository: the next deploy wipes it, or the other way round, the migration clashes | Fix it in a migration and deploy that. No exceptions |
| Business logic in a surprise trigger | An INSERT does things that aren't in the code and nobody can find them |
Triggers for integrity and auditing; visible logic in the code (10-05) |
A NULL that means several things |
"I don't know", "not applicable" and "zero" aren't the same | Split into columns or explicit values (04-03) |
| Rounding at the end of a chain of averages | An average of averages, accumulated errors | Round only when presenting (11-04) |
- Query review checklist
Before calling a query done, in this order:
Correctness
- Do the
JOINs multiply rows? Check the count before and after each one: ifordersgoes from 20 to 47, you're adding up lines, not orders. - Is there a
LEFT JOINwhere the business allows absence? The 10 web orders with no sales rep disappear with anINNER JOIN. - What happens with
NULLs? In theWHERE, in theNOT IN, in the aggregates, in the concatenations. - Does the result tally with a known figure? If a breakdown's total isn't €727.95, the breakdown is wrong.
- Is the
ORDER BYdeterministic? Without a tie-breaker, two runs can come back in different orders.
Performance
- Are the conditions sargable (a bare column)? Do the indexes it needs exist (08-01)?
- Does it return only the columns and rows that will be used? Does it have a
LIMITif it's headed for a screen? - Have you run it with
EXPLAIN ANALYZEover a realistic volume (08-05)?
Maintainability
- Does it read well? Meaningful aliases, one clause per line, explicit
AS? - Do the comments explain why, not what?
- Is it in the repository, with the business question it answers written next to it?
Security
- Do all the user's values travel as parameters (11-03)?
- Does it return personal data that whoever runs it shouldn't see?
Common Mistakes and Tips
- Quoting identifiers in the
CREATE TABLE."OrderDate"forces you to write it quoted forever. Lowercasesnake_caseand that's the end of it. - Mixing singular and plural, or two FK conventions. The cost isn't aesthetic: it's having to check the dictionary on every query.
- Letting PostgreSQL name your constraints. The generated name ends up in the error message the user sees and changes if the column changes. Name them:
chk_,fk_,uq_,idx_. - Storing money in
FLOATor dates inVARCHAR. It's the most expensive decision of all and the hardest to reverse once there's data in there. - Putting all the validation in the application. A second application will arrive, and a nightly script, and a race condition. Integrity goes in the database.
- Using
TIMESTAMPwithout a time zone for instants. Store in UTC withTIMESTAMPTZand convert when displaying. - Editing a migration that's already been applied. Environments drift out of sync silently. You fix it with a new migration.
- Tip: the first query of any report is
SELECT COUNT(*). If the count changes when you add aJOIN, stop and find out why before going on. - Tip: automate the style with
sqlflufforpg_formatin continuous integration. Code reviews should be arguing about logic, not indentation. - Tip: write the convention down in a file in the repository. Half a page is enough, and it turns "that's how we do it" into something a new colleague can read.
Exercises
Exercise 1
This schema is realistic in the sense that it looks a lot like what you're going to run into. List every naming, type and design problem, and rewrite it.
CREATE TABLE "Customer_Orders" (
"ID" VARCHAR(50) PRIMARY KEY,
"user" VARCHAR(255),
fecha_pedido VARCHAR(20),
total FLOAT,
"Status" VARCHAR(255),
not_active INTEGER,
data TEXT
);Exercise 2
Your team is arguing about where to put the rule "a cancelled order can't take new lines". There are three proposals: (a) validate it in the web form; (b) a CHECK on order_lines; (c) a BEFORE INSERT trigger. (1) Which works and which doesn't, and why? (2) Which would you choose and what would you do as well? (3) Does the answer change if the system also has an admin panel and a nightly import process?
Exercise 3
This query comes to you in a code review, with the note "gives the number of orders and the revenue per sales rep". Run section 7's checklist over it and say what's wrong.
select e.name, count(*), sum(l.quantity*l.unit_price)
from employees e, orders o, order_lines l
where e.id=o.employee_id and o.id=l.order_id
group by e.name;Solutions
Solution 1
The problems, one by one: quoted identifiers with capitals ("Customer_Orders", "ID", "Status"), which force you to write quotes forever; "user" is a reserved word; mixed languages (fecha_pedido alongside Status); ID VARCHAR(50) as the PK when it should be a surrogate integer; fecha_pedido VARCHAR instead of DATE; total FLOAT for money; VARCHAR(255) out of habit; not_active in the negative and as an INTEGER rather than a BOOLEAN; data TEXT, which doesn't say what it holds; no FK to customers; no NOT NULL, no CHECK and no named constraint anywhere.
CREATE TABLE customer_orders (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
customer_id INTEGER NOT NULL
CONSTRAINT fk_customer_orders_customers REFERENCES customers(id) ON DELETE RESTRICT,
order_date DATE NOT NULL,
total NUMERIC(10,2) NOT NULL DEFAULT 0
CONSTRAINT chk_customer_orders_total CHECK (total >= 0),
status VARCHAR(20) NOT NULL
CONSTRAINT chk_customer_orders_status
CHECK (status IN ('pending','paid','shipped','delivered','cancelled')),
active BOOLEAN NOT NULL DEFAULT TRUE,
notes TEXT
);And a question to ask yourself before writing anything: total is a precomputed aggregate. If it can be summed from the lines, perhaps it shouldn't exist; and if it exists for performance, you need the mechanism that maintains it (10-05) and a written decision about why.
Solution 2
1. (b) doesn't work: a CHECK can only look at the row being inserted, and the order's status is in another table. PostgreSQL doesn't allow subqueries in a CHECK precisely because it couldn't guarantee it stays true when the other table changes. (a) works but doesn't protect: it covers one of the several entry paths. (c) does work: a BEFORE INSERT trigger on order_lines can query orders.status and raise a RAISE EXCEPTION (10-05).
2. The trigger, and also the form validation, to give a decent message without going to the server. The database guarantees; the interface explains. And you have to think about the symmetric case —what happens if the order is cancelled after it has lines?— which that trigger doesn't cover.
3. The answer doesn't change, it gets stronger. With three entry paths, form validation protects one in three, and the nightly process is precisely the one that will insert thousands of rows with nobody looking at them. That's the whole argument of section 4.
Solution 3
Four correctness problems and several of form. (1) The COUNT(*) is wrong: joining with order_lines makes each order appear once per line, so it isn't counting orders but lines. It has to be COUNT(DISTINCT o.id). (2) The discount is missing: the amount is quantity * unit_price * (1 - discount), and without it the revenue comes out inflated. (3) The INNER JOIN with employees leaves out the 10 web orders: if the report wants "per sales rep", you have to decide explicitly whether those orders are excluded or show up as "Web" with a LEFT JOIN and a COALESCE. (4) GROUP BY e.name groups by first name: two sales reps with the same name would be merged into one row. It has to group by e.id. On form: comma syntax instead of JOIN, no AS and no column aliases, no uppercase and no ORDER BY.
SELECT e.id, e.name || ' ' || e.last_name AS sales_rep,
COUNT(DISTINCT o.id) AS orders,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM employees AS e
JOIN orders AS o ON o.employee_id = e.id
JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY e.id, e.name, e.last_name
ORDER BY revenue DESC;| id | sales_rep | orders | revenue |
|---|---|---|---|
| 5 | Laia Puig Sanchis | 4 | 191.63 |
| 4 | Óscar Peris Blasco | 4 | 132.90 |
| 6 | Marc Estévez Roig | 2 | 54.30 |
Three sales reps, 10 orders and €378.83 — the phone channel. The other €349.12 are the 10 web orders with no sales rep, and their not appearing here is now a decision, not an oversight.
Conclusion
This lesson added no syntax: it added judgement.
- Naming: lowercase
snake_caseand ASCII only; singular or plural doesn't matter, mixing them does;idfor the PK and<table>_idfor the FK, with the role's name on self-referencing ones; booleans in the positive; no reserved words and nodata/table1; and constraints and indexes named by hand withchk_,fk_,uq_,idx_. - Formatting: one clause per line, explicit
JOIN ... ONinstead of the comma, meaningful aliases, explicitAS, keywords in uppercase, comments that explain why,COMMENT ONfor what belongs to the schema, and an automatic formatter configured in the repository. - Design: normalize by default and denormalize with a measurement and a mechanism behind it; the most restrictive type that works;
NOT NULLby default and everyNULLwith a single meaning; a surrogateidplus aUNIQUEon the natural key; no EAV; and aCHECKfor closed lists, a lookup table for the ones that grow. - Reliability: integrity lives in the database, because a second application will arrive, and a hand-typed
UPDATE, and a bug, and a race condition. SensibleDEFAULTs andTIMESTAMPTZin UTC. - Process: versioned, immutable migrations, code review for SQL too, a test environment with fictional or anonymized data —never production copies with personal data—, tested backups (a backup you've never restored isn't a backup) and slow-query monitoring by total time.
- And the thirteen-point checklist, which starts with the question that prevents the most errors: does this
JOINmultiply rows?
Two of the antipatterns in the table were left with a promise attached: building SQL by concatenation, and the "who can see what" that's been cropping up since module 5. In the next lesson, Security: SQL injection, permissions and roles, both get closed: what exactly an SQL injection is and why it happens; the defence that really works —parameterized queries— written in four languages; what is not a defence; the special case of dynamic identifiers; and then PostgreSQL's permission model in full, with GRANT, REVOKE, roles, ALTER DEFAULT PRIVILEGES, row-level security and a concrete role design for GreenStore.
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
