The whole course has assumed you know which columns you need. And that's nearly always true: an order has a customer, a date, a status and shipping costs, and that doesn't change. But some data has no fixed shape. An olive oil has acidity, olive variety and extraction method; a cream has ingredients, skin type and volume; a bamboo toothbrush has none of those things and does have a bristle type. Adding a column per attribute would give you an eighty-column table that's almost entirely null, and building a key-value pair table —the classic EAV— turns any query into a jigsaw of self-joins.
That's what PostgreSQL has JSONB for: a column storing a document with its own structure, indexable and queryable with SQL. This is where 08-03's promise (GIN indexes over JSON) gets kept, and it covers the case of an API response, user preferences or an event log. You'll see how to build documents, how to read them, how to modify them, how to turn them back into rows so you can keep using all the SQL in this course, and —the most important part of the lesson— what should never go inside a JSON.
Contents
- When a fixed schema isn't enough
JSONversusJSONB- Building documents
- GreenStore's
products.attributescolumn - Accessing:
->,->>,#>,#>> - Searching: containment, existence and JSONPath
- Modifying documents
- Expanding to rows: the bridge back to SQL
- Indexing with GIN
- The design discussion: what does NOT go in JSON
- Common Mistakes and Tips
- Exercises
- Module conclusion
- When a fixed schema isn't enough
Four situations in which the pure relational model gets uncomfortable:
| Situation | Why it hurts in columns | GreenStore example |
|---|---|---|
| Attributes varying by type of item | One column per attribute, almost all null | The oil's acidity, the cream's ingredients, organic certifications |
| Responses from an external API | Somebody else decides their shape, and it changes without warning | Whatever the payment gateway returns |
| Preferences and events | Every user and every event type carries different data | Language, notifications; clicks, errors, traces |
And the three classic ways out, with their price:
| Approach | Advantage | Cost |
|---|---|---|
| One column per attribute | Typed, constraints, cheap indexes | A sparse table; every new attribute is an ALTER TABLE (05-06) |
EAV table (attribute_id, value) |
Flexible, no migrations | Everything is text; a query on three attributes is three self-joins |
A JSONB column |
Flexible and queryable, with indexes of its own | No typing and no referential integrity; easy to misuse |
JSON versus JSONB
JSON versus JSONBPostgreSQL has two types, and choosing the wrong one costs you.
JSON |
JSONB |
|
|---|---|---|
| How it's stored | Literal text, exactly as you wrote it | Decomposed binary (a tree of keys and values) |
| On write | Only validates the syntax: very fast | Parses and normalizes: a bit slower |
| On reading a key | Reparses the whole text every time | Direct access: much faster |
| Whitespace, formatting and key order | Preserved | Lost: reordered internally |
| Duplicate keys | All kept | Keeps the last one |
@>, ?, @@ operators / GIN index |
No / no | Yes / yes |
The practical rule: use JSONB unless you need to preserve the exact text. And that "unless" is very narrow: basically, storing an external service's literal response because you have to verify a digital signature or reproduce it byte for byte. For everything else, JSONB. The difference shows up immediately:
| as_json | as_jsonb |
|---|---|
| {"b": 1, "a": 2, "a": 3} | {"a": 3, "b": 1} |
The json stores the nonsense exactly as it is —repeated a key included—; the jsonb normalizes, sorts and keeps the last value of a.
- Building documents
| Function | What it does | Example |
|---|---|---|
| Literal | Text with ::jsonb |
'{"origin": "Spain"}'::jsonb |
to_jsonb(x) |
Converts any value or row to JSON | to_jsonb(p.*) → the whole product as an object |
jsonb_build_object(k, v, ...) |
An object from alternating keys and values | jsonb_build_object('id', p.id, 'price', p.price) |
jsonb_build_array(a, b, ...) |
An array from separate values | jsonb_build_array('bio', 'vegan') |
jsonb_agg(expr) |
Aggregate: gathers many rows into an array | All the lines of an order |
jsonb_object_agg(k, v) |
Aggregate: turns rows into key-value pairs | {"Food": 256.27, ...} |
The star case —returning a whole order with its lines nested in a single row, which is what an API needs— combines the last three:
SELECT jsonb_build_object(
'order_id', o.id, 'date', o.order_date, 'shipping', o.shipping_cost,
'customer', jsonb_build_object('id', c.id, 'country', c.country,
'name', c.name || ' ' || c.last_name),
'lines', (SELECT jsonb_agg(jsonb_build_object(
'product', p.name, 'quantity', ol.quantity,
'amount', ROUND(ol.quantity * ol.unit_price * (1 - ol.discount), 2))
ORDER BY ol.id)
FROM order_lines AS ol
JOIN products AS p ON p.id = ol.product_id
WHERE ol.order_id = o.id)
) AS order_doc
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE o.id = 1;{"date": "2025-03-04", "shipping": 4.95, "order_id": 1,
"customer": {"id": 1, "country": "Spain", "name": "Lucía Martínez Soler"},
"lines": [{"amount": 23.90, "quantity": 2, "product": "Extra virgin olive oil 500 ml"},
{"amount": 11.70, "quantity": 3, "product": "Organic brown rice 1 kg"},
{"amount": 6.50, "quantity": 2, "product": "Organic chamomile tea 20 bags"}]}One row, one column, order 1 complete with its €42.10 across three lines. Notice two things: jsonb_agg accepts its own ORDER BY inside the parentheses, and the keys come out in a different order from the one you wrote them in, because it's jsonb. This saves the application the work of reassembling flat rows into a nested object, and it's why many modern APIs return straight through whatever the database produces — the link to 11-05.
And for a compact summary, jsonb_object_agg:
-- Careful: an aggregate can't be nested inside another, so you group first (10-02)
WITH by_category AS (
SELECT cat.name, ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
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)
SELECT jsonb_object_agg(name, total) AS revenue FROM by_category;{"Food": 256.27, "Drinks": 195.28, "Personal hygiene": 31.50,
"Sustainable home": 88.58, "Natural cosmetics": 156.32}The five categories with sales and their canonical figures, in a single value.
- GreenStore's
products.attributes column
products.attributes columnFrom here on we work with a new column. It isn't canonical: it's this lesson's example and no other module uses it.
-- ⚠️ NOT CANONICAL: example column from 10-06. Reload 01-06's script when you're done.
ALTER TABLE products ADD COLUMN attributes JSONB;
UPDATE products SET attributes = '{"origin":"Spain","acidity":0.3,"variety":"picual",
"extraction":"cold","certifications":["eu-organic","gluten-free"]}'::jsonb WHERE id = 1;
UPDATE products SET attributes = '{"origin":"Spain","bean_type":"wholegrain","certifications":["eu-organic"]}' WHERE id = 2;
UPDATE products SET attributes = '{"origin":"France","volume_ml":50,"skin_type":"dry",
"ingredients":["aloe vera","jojoba oil"],"certifications":["cosmos-organic","vegan"]}'::jsonb
WHERE id = 6;
UPDATE products SET attributes = '{"origin":"Portugal","volume_ml":200,"skin_type":"normal",
"ingredients":["sweet almond","vitamin E"],"certifications":["vegan"]}'::jsonb WHERE id = 8;
UPDATE products SET attributes = '{"origin":"Portugal","strength":"ceremonial","grams":30,"certifications":["eu-organic","vegan"]}' WHERE id = 15;
UPDATE products SET attributes = '{"origin":"Germany","material":"bamboo","bristles":"soft nylon","certifications":["vegan"]}' WHERE id = 18;Six products with attributes; the other fourteen have attributes at NULL, which is normal for this kind of column and something the queries will have to take into account.
- Accessing:
->, ->>, #>, #>>
->, ->>, #>, #>>Four operators, and the difference between them is the number-one source of errors with JSON in PostgreSQL:
| Operator | Argument | Returns | Example on product 1 |
|---|---|---|---|
-> |
Key (text) or index (integer) | jsonb |
attributes -> 'origin' → "Spain" (with quotes) |
->> |
Key or index | text |
attributes ->> 'origin' → Spain |
#> |
Path: an array of text | jsonb |
attributes #> '{certifications,0}' → "eu-organic" |
#>> |
Path | text |
attributes #>> '{certifications,0}' → eu-organic |
The mnemonic: the double arrow >> pulls the value out "raw", as text. The single one still returns JSON, and that lets you chain: attributes -> 'certifications' ->> 0 steps down into the array with -> and pulls the first element out as text with ->>.
SELECT id, name,
attributes -> 'origin' AS origin_jsonb,
attributes ->> 'origin' AS origin_text,
(attributes ->> 'volume_ml')::int AS volume_ml,
attributes -> 'certifications' AS certifications,
attributes #>> '{certifications,0}' AS first_cert
FROM products
WHERE attributes IS NOT NULL
ORDER BY id;| id | name | origin_jsonb | origin_text | volume_ml | certifications | first_cert |
|---|---|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | "Spain" | Spain | (null) | ["eu-organic", "gluten-free"] | eu-organic |
| 2 | Organic brown rice 1 kg | "Spain" | Spain | (null) | ["eu-organic"] | eu-organic |
| 6 | Aloe vera face cream 50 ml | "France" | France | 50 | ["cosmos-organic", "vegan"] | cosmos-organic |
| 8 | Almond body oil 200 ml | "Portugal" | Portugal | 200 | ["vegan"] | vegan |
| 15 | Ceremonial matcha green tea 30 g | "Portugal" | Portugal | (null) | ["eu-organic", "vegan"] | eu-organic |
| 18 | Bamboo toothbrush | "Germany" | Germany | (null) | ["vegan"] | vegan |
Three things to spot in that table. First: "Spain" with quotes isn't Spain; comparing attributes -> 'origin' = 'Spain' fails because there's a jsonb on the left and a text on the right — you have to use ->>, or compare against '"Spain"'::jsonb. Second: everything coming out of ->> is text and has to be cast (::int, ::numeric) to compare as a number; otherwise '200' < '50' is true because it compares alphabetically. Third: a key that doesn't exist returns NULL, not an error — convenient, and dangerous, because a mistyped key doesn't complain.
- Searching: containment, existence and JSONPath
The previous operators extract; these filter, and they're the ones that make use of the GIN index.
| Operator | Reads as "…" | Example |
|---|---|---|
@> |
contains | attributes @> '{"origin":"Spain"}' |
<@ |
is contained in | '{"origin":"Spain"}' <@ attributes |
? |
the key exists (or the element, in an array) | attributes ? 'acidity' |
?| / ?& |
any / all of these keys exist | attributes ?& ARRAY['origin','certifications'] |
SELECT id, name, attributes ->> 'origin' AS origin
FROM products
WHERE attributes @> '{"certifications": ["vegan"]}'
ORDER BY id;| id | name | origin |
|---|---|---|
| 6 | Aloe vera face cream 50 ml | France |
| 8 | Almond body oil 200 ml | Portugal |
| 15 | Ceremonial matcha green tea 30 g | Portugal |
| 18 | Bamboo toothbrush | Germany |
Four vegan products. Notice how powerful @> is: it searches inside an array without taking it apart and without knowing which position the element is in. And SELECT id FROM products WHERE attributes ? 'volume_ml' returns 2 rows —products 6 and 8, the only ones with that key— which is how you ask "which products have this attribute defined".
JSONPath
For conditions @> can't express —numeric comparisons, filters inside arrays, expressions— PostgreSQL 12 added JSONPath, an XPath-style path language:
| Operator / function | What it does |
|---|---|
@? |
Does the path find anything? Returns a boolean |
@@ |
Is the JSONPath expression true? |
jsonb_path_query(doc, path) |
Returns every matching value, as rows |
jsonb_path_query_first / _array |
The first one / all of them in an array |
SELECT id, name FROM products WHERE attributes @? '$.volume_ml ? (@ > 100)'; -- 1
SELECT DISTINCT jsonb_path_query(attributes, '$.certifications[*]') #>> '{}' AS certification
FROM products WHERE attributes IS NOT NULL ORDER BY 1; -- 2The first returns one row, product 8 (body oil, 200 ml): number 6 has 50 and the rest don't have the key. The second returns five: cosmos-organic, eu-organic, gluten-free, vegan and —as soon as you add any new product— whatever it brings with it. Read $.volume_ml ? (@ > 100) like this: $ is the root of the document, .volume_ml steps down to that key, ? (...) is a filter and @ is the current value. With [*] you walk every element of an array.
- Modifying documents
| Operation | How | Example |
|---|---|---|
| Merge | || |
attributes || '{"stock_min": 10}'::jsonb — adds or overwrites matching keys |
| Delete a key | - with text |
attributes - 'acidity' |
| Delete several / by path | - with an array / #- |
attributes - ARRAY['acidity','variety'], attributes #- '{certifications,1}' |
| Set a value | jsonb_set(doc, path, value [, create]) |
Changes it if it exists; with the 4th argument at true (the default) it creates it |
| Insert into an array | jsonb_insert(doc, path, value [, after]) |
Adds without overwriting; fails if the path already exists |
UPDATE products
SET attributes = jsonb_set(attributes, '{acidity}', '0.25'::jsonb)
|| '{"reviewed": true}'::jsonb
WHERE id = 1;
SELECT attributes ->> 'acidity' AS acidity, attributes ->> 'reviewed' AS reviewed
FROM products WHERE id = 1;| acidity | reviewed |
|---|---|
| 0.25 | true |
And here's the performance detail you have to internalize: an UPDATE on a JSONB column rewrites the entire document. There's no such thing as "updating a key"; PostgreSQL creates a new version of the whole row with the whole document (it's MVCC, 09-02). Changing a boolean in a 200 KB document writes 200 KB. The practical consequence: JSONB is for writing rarely and reading a lot. If a field is updated constantly, that field wants to be a column.
A fine point: jsonb_set with a SQL NULL in any argument returns NULL, not the untouched document — and since it's an UPDATE, it wipes the entire document without warning. Use COALESCE, or jsonb_set(COALESCE(attributes, '{}'::jsonb), ...), when the column can be null.
- Expanding to rows: the bridge back to SQL
This section is the one that connects JSON to everything else in the course: turning a document into rows so you can group, join, sort and apply window functions to it.
| Function | Converts | Into |
|---|---|---|
jsonb_array_elements(doc) |
A JSON array | One row per element (jsonb) |
jsonb_array_elements_text(doc) |
A JSON array | One row per element (text) |
jsonb_each(doc) / jsonb_object_keys(doc) |
An object | One row per pair (key, value) / per key |
jsonb_to_record / jsonb_to_recordset |
An object / an array of objects | Rows with typed columns |
SELECT cert.value AS certification, COUNT(*) AS products
FROM products AS p
CROSS JOIN LATERAL jsonb_array_elements_text(p.attributes -> 'certifications') AS cert(value)
WHERE p.attributes ? 'certifications'
GROUP BY cert.value
ORDER BY products DESC, certification;| certification | products |
|---|---|
| vegan | 4 |
| eu-organic | 3 |
| cosmos-organic | 1 |
| gluten-free | 1 |
And there's nothing JSON about this any more: it's a module 4 GROUP BY over ordinary rows, with 07-04's LATERAL acting as the bridge. That's exactly the point: once expanded, a document is just another table, and everything you've learned across eleven lessons applies again. jsonb_to_recordset goes one step further and produces typed columns directly:
SELECT * FROM jsonb_to_recordset('[{"product_id":1,"quantity":2},{"product_id":15,"quantity":1}]'::jsonb)
AS t(product_id INTEGER, quantity INTEGER);| product_id | quantity |
|---|---|
| 1 | 2 |
| 15 | 1 |
It's the canonical way to receive a list of order lines from an API and turn it into insertable rows with a single INSERT ... SELECT — far cleaner than the two parallel arrays of sp_confirm_order in 10-04.
- Indexing with GIN
This is where 08-03's promise gets kept. Without an index, every query with @> walks the whole table and parses every document. With a GIN index, it doesn't.
CREATE INDEX ix_products_attributes ON products USING GIN (attributes); -- jsonb_ops
CREATE INDEX ix_products_attributes_pth ON products USING GIN (attributes jsonb_path_ops); -- jsonb_path_opsjsonb_ops (the default) |
jsonb_path_ops |
|
|---|---|---|
| What it indexes | Every key and every value separately | A hash of the whole path down to the value |
| Operators supported | @>, <@, ?, ?|, ?&, @?, @@ |
Only @>, @? and @@ |
Size / speed with @> |
Larger / good | Considerably smaller / better |
The criterion: if you only query by containment (@>), jsonb_path_ops, which is smaller and faster. If you need to ask about key existence (?), there's no way around jsonb_ops. And remember from 08-03 that a GIN is built and maintained slowly: it's the "write rarely, read a lot" index, which happens to be the profile of a JSONB column too.
And the third way, which is often the best: a B-tree over an expression when the query always goes through the same key.
CREATE INDEX ix_products_origin ON products ((attributes ->> 'origin'));
SELECT id, name FROM products WHERE attributes ->> 'origin' = 'Portugal';It returns products 8 and 15. A B-tree over (attributes ->> 'origin') is tiny compared with a GIN, supports ranges and ordering, and it's the right choice when one specific key is queried a lot. The double parentheses are compulsory: it's an expression index, 08-02's kind — and it works because ->> is IMMUTABLE, the condition 10-04 explained.
- The design discussion: what does NOT go in JSON
The most important part of the lesson. JSONB is so convenient that it tempts you to put everything inside it, and that's a decision you pay for over years.
What must NOT go in a JSON:
- Anything you filter or join on constantly.
category_idinside a JSON turns an indexJOINinto a query you have to rewrite with->>and cast on every use. - Anything with referential integrity. A foreign key can't point inside a document. If you store
{"supplier_id": 7}and somebody deletes supplier 7, nobody warns you: you've just lost the guarantee 01-05 gave you for free. - Anything that's really a table. An array of a thousand orders inside a customer's document is an
orderstable in disguise, with no indexes of its own, unable to be queried on its own and rewritten in full with every purchase. - Anything with rules. There's no
NOT NULL, noUNIQUEand noCHECKinside a JSON: apricein JSON can end up being"expensive"and nothing will stop it. And anything updated constantly, because everyUPDATErewrites the whole document (section 7).
The antipattern: using the relational database as a document store out of laziness about modelling. You recognize it by a table with an
idand adata JSONBcolumn holding everything. It works for the first month, and from the sixth on every query is a castle of->>and::numeric, nothing has an index, nothing has integrity and nobody knows which keys exist. If you know what the fields are, they're columns. If you genuinely need a document store, there are databases that do that far better.
And the table that settles the specific question:
| Question | If the answer is yes → |
|---|---|
| Do all rows have it, and do you know what it is? | Column |
| Is it filtered, sorted or joined on frequently? | Column |
Does it need NOT NULL, UNIQUE, CHECK or an FK? Is it updated often on its own? |
Column |
| Is it a list of entities with a life of their own? Does it need querying or aggregating on its own? | Related table |
| Does it vary by row type and only get read alongside the rest? Does it come from outside in a shape you don't control? | JSON |
| Is it optional, sparse and rarely used? | JSON |
Applied to GreenStore: price, stock and category_id are columns, no argument; the order lines are a table, not an array inside orders; and the oil's acidity or a cream's skin type are JSON, because each category has its own and they're only shown on the product page.
Dialect note: MySQL 8 has a binary
JSONtype with->and->>(with PostgreSQL's semantics),JSON_EXTRACT,JSON_TABLEto expand to rows, and no indexes over JSON: you index generated columns instead. SQLite ships the JSON1 extension compiled in by default:json_extract(),json_each(), the->>operator from 3.38 on, and everything stored as text. SQL Server stores JSON inNVARCHARand queries it withJSON_VALUE,JSON_QUERYandOPENJSONto expand to rows, with indexes over computed columns. Oracle has had a nativeJSONtype since 21c and supports JSONPath extensively. The SQL:2016 standard defines JSONPath, which is whyjsonb_path_querylooks so similar across engines; everything else is pure dialect.
Common Mistakes and Tips
- Confusing
->with->>. The first returnsjsonb("Spain", with quotes) and the secondtext(Spain).attributes -> 'origin' = 'Spain'never matches. - Comparing numbers without casting.
attributes ->> 'volume_ml' > '100'compares text:'50'is greater than'100'. Always cast:(attributes ->> 'volume_ml')::int. - Mistyping a key. It doesn't error: it returns
NULLand the row disappears silently. Check with?which keys really exist. And usingJSONinstead ofJSONB: no containment operators, no GIN indexes and reparsing the text on every access.JSONBunless you need the exact literal text. - Forgetting that an
UPDATErewrites the whole document. With large documents and frequent updates, that means bloat andVACUUMwork (09-02). jsonb_seton aNULLcolumn. It returnsNULLand wipes the document.COALESCE(attributes, '{}'::jsonb).- Indexing with GIN by default and not measuring. If you only use
@>,jsonb_path_opstakes up considerably less; and if you always query the same key, a B-tree over the expression beats both. - Putting something with a foreign key into JSON. There's no referential integrity inside a document, and there never will be. Tip: document the expected keys. A JSON with no documented schema is a free-text field. If the set of keys is closed, validate it with a
CHECK (attributes ?& ARRAY['origin'])or with a schema-validation extension. - Tip: start with columns and move to JSON only what's left over. The other way round doesn't work: pulling three years of data out of a JSON into typed columns is an expensive migration.
- Tip:
jsonb_pretty(attributes)to read a document inpsql. And\xfor expanded mode.
Exercises
Exercise 1
With the attributes column loaded as in section 4: (1) List the Spanish-origin products with their acidity, if they have one. (2) Count how many products there are per origin, sorted from most to fewest. (3) Find all the ones with aloe vera among their ingredients, using @>. (4) Add the key "reviewed": false to every product that has attributes, without overwriting anything already there.
Exercise 2
Marketing wants the catalogue in JSON for the website: an array with one object per category, and inside each one, the category's name and the array of its active products with id, name and price. Write it with jsonb_agg and jsonb_build_object, and say how many elements the outer array has.
Exercise 3
Using section 10's table, decide where each piece of data goes and justify it in one sentence:
- A product's average rating, shown in the listing and used for sorting.
- The packaging dimensions (height, width, depth), known for only some products and used only when computing shipping.
- A product's price change history.
- The full response from the payment gateway when charging an order.
- A product's supplier.
Solutions
Solution 1
-- 1
SELECT id, name, (attributes ->> 'acidity')::numeric AS acidity
FROM products WHERE attributes @> '{"origin":"Spain"}' ORDER BY id;
-- 2
SELECT attributes ->> 'origin' AS origin, COUNT(*) AS products
FROM products WHERE attributes ? 'origin' GROUP BY 1 ORDER BY products DESC, origin;
-- 3
SELECT id, name FROM products WHERE attributes @> '{"ingredients":["aloe vera"]}';
-- 4
UPDATE products SET attributes = '{"reviewed": false}'::jsonb || attributes
WHERE attributes IS NOT NULL;1 returns two rows: the oil (id 1) with acidity 0.25 —the one section 7's jsonb_set left behind— and the rice (id 2) with NULL, because it doesn't have that key. 2 returns Portugal 2, Spain 2, France 1 and Germany 1: four origins for the six products with attributes. 3 returns one row, the face cream (id 6), and it works because @> searches inside the array regardless of position. 4 affects 6 rows, and the order of the || is the key: '{"reviewed": false}' || attributes makes attributes win if the key already existed, whereas attributes || '{"reviewed": false}' would overwrite it. It's the difference between "add if missing" and "force the value".
Solution 2
SELECT jsonb_agg(jsonb_build_object(
'category', cat.name,
'products', (SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', p.id, 'name', p.name, 'price', p.price)
ORDER BY p.id), '[]'::jsonb)
FROM products AS p
WHERE p.category_id = cat.id AND p.active)
) ORDER BY cat.id) AS catalog
FROM categories AS cat;The outer array has 6 elements, one per category, including Supplements — which shows up with "products": [] because its only product, the Spirulina capsules, is discontinued (active = FALSE). That COALESCE(..., '[]'::jsonb) isn't decoration: without it, jsonb_agg over zero rows returns NULL and the key would come out as null instead of an empty array, which would break any code walking the list. It's exactly 04-04's problem with SUM over the empty set, now in JSON.
Solution 3
| # | Data | Where | Why |
|---|---|---|---|
| 1 | Average rating | Column (or view/materialized view) | The listing sorts and filters by it: inside a JSON it wouldn't have a usable index |
| 2 | Packaging dimensions | JSON | Optional, sparse and only read alongside the rest of the product when computing shipping |
| 3 | Price history | Related table | It's a list of entities with a life of their own, which has to be queried and aggregated on its own: it's 10-05's price_audit |
| 4 | Gateway response | JSON | It comes from outside in a shape you don't control and that can change without warning. Here even JSON instead of JSONB fits, if you have to verify a signature over the exact text |
| 5 | Supplier | Column with an FK | It has referential integrity: inside a document, ON DELETE RESTRICT doesn't exist |
All five answers come out of asking three questions: is it filtered or sorted on? (column), does it have a life of its own? (table), is it optional, variable and only read together with the rest? (JSON).
Module conclusion
You close the module with the last piece of the toolbox:
JSONBversusJSON: decomposed binary versus literal text.JSONBnormalizes keys, removes duplicates, accesses fast and is the only one that can be indexed and has containment operators. The rule:JSONBunless you need the exact text.- Building:
to_jsonb,jsonb_build_object,jsonb_build_array, and above all the aggregatesjsonb_aggandjsonb_object_agg, which return order 1 complete with its three lines and its €42.10 in a single row — the star case for an API (11-05). - Accessing:
->and#>returnjsonb;->>and#>>returntext. That difference is the number-one source of errors, along with comparing numbers without casting and with non-existent keys returningNULLsilently. - Searching:
@>(containment, which reaches inside arrays),?,?|and?&(key existence), and JSONPath with@?,@@andjsonb_path_queryfor what containment can't express, such as$.volume_ml ? (@ > 100). - Modifying:
||merges,-deletes,jsonb_setsets andjsonb_insertadds — remembering that anUPDATErewrites the whole document, and that's whyJSONBis for writing rarely and reading a lot. - Expanding to rows with
jsonb_array_elements,jsonb_eachandjsonb_to_recordsetis the bridge back: once expanded, a document is just another table and all the SQL in the course works again — like the certification count, withveganon 4 products andeu-organicon 3. - Indexing, closing 08-03: GIN with
jsonb_opssupports every operator and takes up more space;jsonb_path_opsonly@>,@?and@@, but it's smaller and faster; and a B-tree over(attributes ->> 'key')beats both when you always query the same key. - And the design discussion: keep out of the JSON everything you filter or join on, everything with referential integrity, everything that's really a table, everything that needs rules and everything updated constantly. The antipattern —using a relational database as a document store out of laziness about modelling— is paid back with interest. If you know what the fields are, they're columns.
And with that module 10 closes. In six lessons you've assembled the complete toolbox: views that give a query a name and encapsulate a metric, with materialized ones for expensive reports; CTEs that turn three levels of subqueries into readable steps, and WITH RECURSIVE to walk the whole org chart and the three-hop referral chain; window functions that aggregate without collapsing and solve rankings, running totals, moving averages and top N per group; procedures where order confirmation finally lives with its guaranteed atomicity; triggers that apply on their own the rules a CHECK can't reach; and JSON for what doesn't fit in a fixed schema. You're no longer learning SQL: you're using it.
What's missing isn't more functions, but context. A real system isn't a well-written query: it's a set of decisions about how the work is organized, who can see what, what gets measured and where it's called from. In module 11, Practice: real-world use cases, you'll see SQL in its environment: the use cases that come up again and again in any project; the best practices of writing, naming and maintenance that separate a database you can work with from one you're scared to touch; security —SQL injection and how it's really prevented, the permissions and roles this module has been deferring lesson after lesson—; SQL for data analysis, where 10-03's window functions turn into complete reports; and SQL in web development, with ORMs, the connection pool and the N+1 problem. The toolbox is full now; what's left is learning the craft.
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
